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

roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 9174fa7d12 [Cherry-pick to branch-1.3] [#12777] fix: Avoid exposing 
service admins publicly (#12778) (#12821)
9174fa7d12 is described below

commit 9174fa7d1232053dc03a49d5129f1fff6915a29d
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Sep 2 14:13:55 2026 +0800

    [Cherry-pick to branch-1.3] [#12777] fix: Avoid exposing service admins 
publicly (#12778) (#12821)
    
    **Cherry-pick Information:**
    - Original commit: ea76f14080fcda8d071798367a3c05e18ad45209
    - Target branch: `branch-1.3`
    - Status:  conflicts resolved
    
    ---------
    
    Co-authored-by: roryqi <[email protected]>
    Co-authored-by: Qian Xia <[email protected]>
    Co-authored-by: roryqi <[email protected]>
---
 .../gravitino/dto/responses/AuthMeResponse.java    | 17 ++++++-
 .../gravitino/dto/responses/TestResponses.java     |  6 ++-
 conf/gravitino.conf.template                       |  2 -
 docs/gravitino-server-config.md                    |  6 +--
 docs/open-api/authn.yaml                           | 19 +++++---
 docs/webui-v2.md                                   |  4 +-
 .../apache/gravitino/server/web/ConfigServlet.java |  9 ----
 .../gravitino/server/web/rest/AuthnOperations.java | 27 +++++++++--
 .../gravitino/server/web/TestConfigServlet.java    | 16 +------
 .../server/web/rest/TestAuthnOperations.java       | 22 ++++++++-
 web-v2/web/src/app/login/components/SimpleLogin.js |  9 ++--
 web-v2/web/src/app/metalakes/page.js               | 10 ++--
 web-v2/web/src/app/rootLayout/UserSetting.js       | 10 ++--
 web-v2/web/src/lib/provider/session.js             | 25 ++++------
 web-v2/web/src/lib/store/auth/index.js             | 42 ++++++++++++++---
 web-v2/web/src/lib/store/auth/index.test.js        | 53 ++++++++++++++++++++++
 web-v2/web/src/lib/utils/metalakePermissions.js    | 21 +++++++++
 .../web/src/lib/utils/metalakePermissions.test.js  | 33 ++++++++++++++
 18 files changed, 252 insertions(+), 79 deletions(-)

diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java 
b/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java
index ac9dd1ce5d..6bc6971bbb 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java
@@ -23,7 +23,7 @@ import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.ToString;
 
-/** Response for the authenticated principal information. */
+/** Response for the authenticated principal and service-administrator status. 
*/
 @Getter
 @EqualsAndHashCode(callSuper = true)
 @ToString
@@ -32,20 +32,35 @@ public class AuthMeResponse extends BaseResponse {
   @JsonProperty("principal")
   private final String principal;
 
+  @JsonProperty("serviceAdmin")
+  private final boolean serviceAdmin;
+
   /**
    * Constructor for AuthMeResponse.
    *
    * @param principal The server-resolved principal name of the authenticated 
user.
    */
   public AuthMeResponse(String principal) {
+    this(principal, false);
+  }
+
+  /**
+   * Constructor for AuthMeResponse.
+   *
+   * @param principal The server-resolved principal name of the authenticated 
user.
+   * @param serviceAdmin Whether the authenticated user is a service 
administrator.
+   */
+  public AuthMeResponse(String principal, boolean serviceAdmin) {
     super(0);
     this.principal = principal;
+    this.serviceAdmin = serviceAdmin;
   }
 
   /** Default constructor for AuthMeResponse. (Used for Jackson 
deserialization.) */
   public AuthMeResponse() {
     super();
     this.principal = null;
+    this.serviceAdmin = false;
   }
 
   @Override
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
index f1c5977eda..b5bd4dbda7 100644
--- a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
+++ b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.dto.responses;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 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.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -516,16 +517,18 @@ public class TestResponses {
 
   @Test
   void testAuthMeResponse() throws JsonProcessingException {
-    AuthMeResponse response = new AuthMeResponse("test-user");
+    AuthMeResponse response = new AuthMeResponse("test-user", true);
     response.validate();
     assertEquals(0, response.getCode());
     assertEquals("test-user", response.getPrincipal());
+    assertTrue(response.isServiceAdmin());
 
     String serJson = JsonUtils.objectMapper().writeValueAsString(response);
     AuthMeResponse deserResponse =
         JsonUtils.objectMapper().readValue(serJson, AuthMeResponse.class);
     assertEquals(response.getCode(), deserResponse.getCode());
     assertEquals(response.getPrincipal(), deserResponse.getPrincipal());
+    assertEquals(response.isServiceAdmin(), deserResponse.isServiceAdmin());
   }
 
   @Test
@@ -533,6 +536,7 @@ public class TestResponses {
     AuthMeResponse response = new AuthMeResponse();
     assertDoesNotThrow(response::validate);
     assertNull(response.getPrincipal());
+    assertFalse(response.isServiceAdmin());
   }
 
   @Test
diff --git a/conf/gravitino.conf.template b/conf/gravitino.conf.template
index c1ebfeee90..81363667e0 100644
--- a/conf/gravitino.conf.template
+++ b/conf/gravitino.conf.template
@@ -50,8 +50,6 @@ gravitino.server.webserver.responseHeaderSize = 131072
 # UI warning countdown duration in milliseconds before the inactivity timeout. 
The UI environment
 # variable NEXT_PUBLIC_IDLE_WARNING_LEAD_MS is used when this config is not 
set.
 # gravitino.ui.sessionIdleWarningLeadMs = 60000
-# Multiple visibleConfigs are split by commas.
-# gravitino.server.visibleConfigs = 
gravitino.ui.sessionIdleTimeoutMs,gravitino.ui.sessionMaxDurationMs,gravitino.ui.sessionIdleWarningLeadMs
 
 # THE CONFIGURATION FOR Gravitino ENTITY STORE
 # The entity store to use, we only supports relational
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 707791a1fd..f85f56a6d3 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -179,9 +179,9 @@ filter with properties of the form
 
 `GET /configs` backs the Web UI, so it answers without authentication and 
always returns
 `gravitino.authenticators`, `gravitino.authorization.enable`, and 
`gravitino.schema.separator`.
-It adds `gravitino.authorization.serviceAdmins` when authorization is on, and 
the OAuth client
-settings when `oauth` is among the authenticators. Treat anything you add 
through
-`visibleConfigs` as public.
+It adds the OAuth client settings when `oauth` is among the authenticators. 
Treat anything you add
+through `visibleConfigs` as public, and only add properties that a client 
needs before it can
+authenticate.
 
 Two further groups of `gravitino.server.webserver.*` properties are documented 
elsewhere, because
 they belong to features rather than to the web server itself. TLS, key stores, 
trust stores, and
diff --git a/docs/open-api/authn.yaml b/docs/open-api/authn.yaml
index 05c3918771..95d72eb21e 100644
--- a/docs/open-api/authn.yaml
+++ b/docs/open-api/authn.yaml
@@ -23,15 +23,16 @@ paths:
     get:
       tags:
         - authentication
-      summary: Get the authenticated principal
-      operationId: getAuthenticatedPrincipal
+      summary: Get the authenticated user
+      operationId: getAuthenticatedUser
       description: >
-        Returns the server-resolved principal name for the current 
authenticated user.
-        The principal is derived from the JWT token using the configured 
`principalFields`
-        and `principalMapper`, ensuring consistency between server-side 
identity and UI display.
+        Returns the server-resolved principal name and service-administrator 
status for the
+        current authenticated user.
+        The principal is derived from the configured authenticator and 
principal mapper, ensuring
+        consistency between server-side identity and UI display.
       responses:
         "200":
-          description: Returns the authenticated principal information
+          description: Returns the authenticated user information
           content:
             application/vnd.gravitino.v1+json:
               schema:
@@ -62,10 +63,14 @@ components:
         principal:
           type: string
           description: The server-resolved principal name of the authenticated 
user
+        serviceAdmin:
+          type: boolean
+          description: Whether the authenticated user is a Gravitino service 
administrator
 
   examples:
     AuthMeResponse:
       value: {
         "code": 0,
-        "principal": "admin"
+        "principal": "admin",
+        "serviceAdmin": true
       }
diff --git a/docs/webui-v2.md b/docs/webui-v2.md
index 2e0ccd3a82..5b82863979 100644
--- a/docs/webui-v2.md
+++ b/docs/webui-v2.md
@@ -50,7 +50,9 @@ gravitino.ui.sessionIdleWarningLeadMs = 60000
 gravitino.server.visibleConfigs = 
gravitino.ui.sessionIdleTimeoutMs,gravitino.ui.sessionMaxDurationMs,gravitino.ui.sessionIdleWarningLeadMs
 ```
 
-`gravitino.authorization.serviceAdmins` is exposed automatically by `/configs` 
when authorization is enabled and does not need to be added to 
`gravitino.server.visibleConfigs`.
+Do not add `gravitino.authorization.serviceAdmins` to 
`gravitino.server.visibleConfigs`, because
+values returned by the unauthenticated `/configs` endpoint are public. The UI 
obtains the current
+user's service-administrator status from the authenticated `/api/authn/me` 
endpoint.
 
 ## Web V2
 
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/ConfigServlet.java 
b/server/src/main/java/org/apache/gravitino/server/web/ConfigServlet.java
index 9ea12fa474..f1cc36cb7e 100644
--- a/server/src/main/java/org/apache/gravitino/server/web/ConfigServlet.java
+++ b/server/src/main/java/org/apache/gravitino/server/web/ConfigServlet.java
@@ -66,15 +66,6 @@ public class ConfigServlet extends HttpServlet {
       configs.put(key.getKey(), serverConfig.get(key));
     }
 
-    if (serverConfig.get(Configs.ENABLE_AUTHORIZATION)) {
-      // Expose serviceAdmins when authorization is enabled so the web UI can 
determine whether the
-      // logged-in user has service-admin privileges (e.g. to show the "Create 
Metalake" button).
-      String serviceAdminsRaw = 
serverConfig.getRawString(Configs.SERVICE_ADMINS.getKey());
-      if (serviceAdminsRaw != null) {
-        configs.put(Configs.SERVICE_ADMINS.getKey(), 
serverConfig.get(Configs.SERVICE_ADMINS));
-      }
-    }
-
     if (serverConfig
         .get(Configs.AUTHENTICATORS)
         .contains(AuthenticatorType.OAUTH.name().toLowerCase())) {
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
index 4656b5e683..43d340b2d2 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
@@ -20,12 +20,15 @@ package org.apache.gravitino.server.web.rest;
 
 import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
+import javax.annotation.Nullable;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.GET;
 import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.Response;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.authorization.AccessControlDispatcher;
 import org.apache.gravitino.dto.responses.AuthMeResponse;
 import org.apache.gravitino.metrics.MetricNames;
 import org.apache.gravitino.server.web.Utils;
@@ -33,14 +36,25 @@ import org.apache.gravitino.utils.PrincipalUtils;
 
 /**
  * Provides the authenticated principal information. This endpoint returns the 
server-resolved
- * principal name, ensuring the UI identity matches the server-side identity 
derived from the
- * configured {@code principalFields} and {@code principalMapper}.
+ * principal name and service-administrator status, ensuring the UI uses 
server-resolved identity
+ * and authorization information.
  */
 @Path("/authn")
 public class AuthnOperations {
 
+  @Nullable private final AccessControlDispatcher accessControlDispatcher;
+
   @Context private HttpServletRequest httpRequest;
 
+  /** Creates an authenticated-principal REST resource. */
+  public AuthnOperations() {
+    this(GravitinoEnv.getInstance().accessControlDispatcher());
+  }
+
+  AuthnOperations(@Nullable AccessControlDispatcher accessControlDispatcher) {
+    this.accessControlDispatcher = accessControlDispatcher;
+  }
+
   @GET
   @Path("me")
   @Produces("application/vnd.gravitino.v1+json")
@@ -49,7 +63,14 @@ public class AuthnOperations {
   public Response me() {
     try {
       return Utils.doAs(
-          httpRequest, () -> Utils.ok(new 
AuthMeResponse(PrincipalUtils.getCurrentUserName())));
+          httpRequest,
+          () -> {
+            String principal = PrincipalUtils.getCurrentUserName();
+            boolean serviceAdmin =
+                accessControlDispatcher != null
+                    && accessControlDispatcher.isServiceAdmin(principal);
+            return Utils.ok(new AuthMeResponse(principal, serviceAdmin));
+          });
     } catch (Exception e) {
       return Utils.internalError(e.getMessage(), e);
     }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/TestConfigServlet.java 
b/server/src/test/java/org/apache/gravitino/server/web/TestConfigServlet.java
index 10b163e706..be52b8d15c 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/TestConfigServlet.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/TestConfigServlet.java
@@ -64,25 +64,13 @@ public class TestConfigServlet {
   }
 
   @Test
-  public void testConfigServletWithAuthEnabledButNoServiceAdmins() throws 
Exception {
-    // When authorization is enabled but serviceAdmins is not configured, the 
key should be
-    // absent from the response (no crash) rather than null or empty.
-    ServerConfig serverConfig = new ServerConfig();
-    serverConfig.set(Configs.ENABLE_AUTHORIZATION, true);
-    Map<String, Object> configs = fetchConfigs(serverConfig);
-    Assertions.assertEquals(true, 
configs.get(Configs.ENABLE_AUTHORIZATION.getKey()));
-    
Assertions.assertFalse(configs.containsKey(Configs.SERVICE_ADMINS.getKey()));
-  }
-
-  @Test
-  public void testConfigServletWithAuthEnabledAndServiceAdmins() throws 
Exception {
+  public void testConfigServletDoesNotImplicitlyExposeServiceAdmins() throws 
Exception {
     ServerConfig serverConfig = new ServerConfig();
     serverConfig.set(Configs.ENABLE_AUTHORIZATION, true);
     serverConfig.set(Configs.SERVICE_ADMINS, Lists.newArrayList("admin1", 
"admin2"));
     Map<String, Object> configs = fetchConfigs(serverConfig);
     Assertions.assertEquals(true, 
configs.get(Configs.ENABLE_AUTHORIZATION.getKey()));
-    Assertions.assertEquals(
-        Lists.newArrayList("admin1", "admin2"), 
configs.get(Configs.SERVICE_ADMINS.getKey()));
+    
Assertions.assertFalse(configs.containsKey(Configs.SERVICE_ADMINS.getKey()));
   }
 
   @Test
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.java
index f55edd7167..159a076781 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.java
@@ -27,6 +27,7 @@ import javax.ws.rs.core.Application;
 import javax.ws.rs.core.Response;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.authorization.AccessControlDispatcher;
 import org.apache.gravitino.dto.responses.AuthMeResponse;
 import org.apache.gravitino.rest.RESTUtils;
 import org.apache.gravitino.server.web.ObjectMapperProvider;
@@ -39,6 +40,8 @@ import org.junit.jupiter.api.Test;
 public class TestAuthnOperations extends BaseOperationsTest {
 
   private static final String TEST_PRINCIPAL = "test-user";
+  private static final AccessControlDispatcher ACCESS_CONTROL_DISPATCHER =
+      mock(AccessControlDispatcher.class);
 
   private static class MockServletRequestFactory extends 
ServletRequestFactoryBase {
     @Override
@@ -61,7 +64,8 @@ public class TestAuthnOperations extends BaseOperationsTest {
     }
 
     ResourceConfig resourceConfig = new ResourceConfig();
-    resourceConfig.register(AuthnOperations.class);
+    
when(ACCESS_CONTROL_DISPATCHER.isServiceAdmin(TEST_PRINCIPAL)).thenReturn(true);
+    resourceConfig.register(new AuthnOperations(ACCESS_CONTROL_DISPATCHER));
     resourceConfig.register(ObjectMapperProvider.class);
     resourceConfig.register(
         new AbstractBinder() {
@@ -76,6 +80,8 @@ public class TestAuthnOperations extends BaseOperationsTest {
 
   @Test
   public void testGetAuthnMe() {
+    
when(ACCESS_CONTROL_DISPATCHER.isServiceAdmin(TEST_PRINCIPAL)).thenReturn(true);
+
     Response resp = 
target("/authn/me").request().accept("application/vnd.gravitino.v1+json").get();
 
     Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
@@ -83,5 +89,19 @@ public class TestAuthnOperations extends BaseOperationsTest {
     AuthMeResponse authMeResponse = resp.readEntity(AuthMeResponse.class);
     Assertions.assertEquals(0, authMeResponse.getCode());
     Assertions.assertEquals(TEST_PRINCIPAL, authMeResponse.getPrincipal());
+    Assertions.assertTrue(authMeResponse.isServiceAdmin());
+  }
+
+  @Test
+  public void testGetAuthnMeForNonServiceAdmin() {
+    
when(ACCESS_CONTROL_DISPATCHER.isServiceAdmin(TEST_PRINCIPAL)).thenReturn(false);
+
+    Response resp = 
target("/authn/me").request().accept("application/vnd.gravitino.v1+json").get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+
+    AuthMeResponse authMeResponse = resp.readEntity(AuthMeResponse.class);
+    Assertions.assertEquals(TEST_PRINCIPAL, authMeResponse.getPrincipal());
+    Assertions.assertFalse(authMeResponse.isServiceAdmin());
   }
 }
diff --git a/web-v2/web/src/app/login/components/SimpleLogin.js 
b/web-v2/web/src/app/login/components/SimpleLogin.js
index 10c94a737b..31ec19edea 100644
--- a/web-v2/web/src/app/login/components/SimpleLogin.js
+++ b/web-v2/web/src/app/login/components/SimpleLogin.js
@@ -20,20 +20,19 @@
 'use client'
 
 import { useRouter } from 'next/navigation'
-import { useEffect } from 'react'
 import { Button, Form, Input } from 'antd'
 
-import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
-import { clearIntervalId, setAuthUser } from '@/lib/store/auth'
+import { useAppDispatch } from '@/lib/hooks/useStore'
+import { getAuthMe, setAuthUser } from '@/lib/store/auth'
 
 function SimpleLogin() {
   const router = useRouter()
   const dispatch = useAppDispatch()
-  const store = useAppSelector(state => state.auth)
   const [form] = Form.useForm()
 
   const onFinish = async values => {
-    await dispatch(setAuthUser({ name: values.username, type: 'user' }))
+    dispatch(setAuthUser({ name: values.username, type: 'user' }))
+    await dispatch(getAuthMe())
     router.push('/metalakes')
   }
 
diff --git a/web-v2/web/src/app/metalakes/page.js 
b/web-v2/web/src/app/metalakes/page.js
index f1d0c684f4..b18ddf4a9c 100644
--- a/web-v2/web/src/app/metalakes/page.js
+++ b/web-v2/web/src/app/metalakes/page.js
@@ -37,6 +37,7 @@ import {
 } from '@/lib/store/metalakes'
 import { to } from '@/lib/utils'
 import { formatToDateTime } from '@/lib/utils/date'
+import { canCreateMetalake } from '@/lib/utils/metalakePermissions'
 import Icons from '@/components/Icons'
 import GetOwner from '@/components/GetOwner'
 import PropertiesContent from '@/components/PropertiesContent'
@@ -69,10 +70,9 @@ const MetalakeList = () => {
   const [search, setSearch] = useState('')
   const [ownerRefreshKey, setOwnerRefreshKey] = useState(0)
   const auth = useAppSelector(state => state.auth)
-  const { serviceAdmins, authUser, anthEnable, authType, authToken } = auth
+  const { isServiceAdmin, authUser, anthEnable, authType, authToken } = auth
+  const showCreateMetalake = canCreateMetalake(anthEnable, isServiceAdmin)
   const isAuthReady = authType && (authType !== 'oauth' || !!authToken)
-  const admins = Array.isArray(serviceAdmins) ? serviceAdmins : (serviceAdmins 
|| '').split(',')
-  const isServiceAdmin = admins.includes(authUser?.name)
   const dispatch = useAppDispatch()
   const store = useAppSelector(state => state.metalakes)
   const [tableData, setTableData] = useState([])
@@ -346,7 +346,7 @@ const MetalakeList = () => {
         }
       }
     ],
-    [anthEnable, ownerRefreshKey]
+    [anthEnable, authUser, isServiceAdmin, ownerRefreshKey]
   )
 
   const { resizableColumns, components, tableWidth } = useAntdColumnResize(() 
=> {
@@ -371,7 +371,7 @@ const MetalakeList = () => {
               placeholder='Search...'
               onChange={onSearchTable}
             />
-            {(isServiceAdmin || !anthEnable) && (
+            {showCreateMetalake && (
               <Button
                 data-refer='create-metalake-btn'
                 type='primary'
diff --git a/web-v2/web/src/app/rootLayout/UserSetting.js 
b/web-v2/web/src/app/rootLayout/UserSetting.js
index 23a4c326bf..b0d1bd2b10 100644
--- a/web-v2/web/src/app/rootLayout/UserSetting.js
+++ b/web-v2/web/src/app/rootLayout/UserSetting.js
@@ -30,6 +30,7 @@ import { fetchMetalakes, resetMetalakeStore } from 
'@/lib/store/metalakes'
 import { resetRolesStore } from '@/lib/store/roles'
 import { logoutAction } from '@/lib/store/auth'
 import { oauthProviderFactory } from '@/lib/auth/providers/factory'
+import { canCreateMetalake } from '@/lib/utils/metalakePermissions'
 
 const CreateMetalakeDialog = dynamic(() => 
import('@/app/metalakes/CreateMetalakeDialog'), {
   loading: () => <Loading />,
@@ -40,9 +41,8 @@ export default function UserSetting() {
   const [openCreateMeta, setOpenCreateMeta] = useState(false)
   const [showLogoutButton, setShowLogoutButton] = useState(false)
   const auth = useAppSelector(state => state.auth)
-  const { serviceAdmins, authUser, anthEnable } = auth
-  const admins = Array.isArray(serviceAdmins) ? serviceAdmins : (serviceAdmins 
|| '').split(',')
-  const isServiceAdmin = admins.includes(authUser?.name)
+  const { isServiceAdmin, authUser, anthEnable } = auth
+  const showCreateMetalake = canCreateMetalake(anthEnable, isServiceAdmin)
   const [session, setSession] = useState({})
   const router = useRouter()
   const pathname = usePathname()
@@ -92,7 +92,7 @@ export default function UserSetting() {
         label: (
           <div className='flex w-[208px] justify-between'>
             <span>Metalakes</span>
-            {isServiceAdmin && (
+            {showCreateMetalake && (
               <Tooltip title='Create Metalake'>
                 <PlusOutlined className='cursor-pointer text-black' 
onClick={handleCreateMetalake} />
               </Tooltip>
@@ -155,7 +155,7 @@ export default function UserSetting() {
           ]
         : [])
     ],
-    [authUser, serviceAdmins, store.metalakes, currentMetalake, anthEnable, 
searchParams]
+    [authUser, showCreateMetalake, store.metalakes, currentMetalake, 
anthEnable, searchParams]
   )
 
   return (
diff --git a/web-v2/web/src/lib/provider/session.js 
b/web-v2/web/src/lib/provider/session.js
index c8f67f4ba2..28f985f1a4 100644
--- a/web-v2/web/src/lib/provider/session.js
+++ b/web-v2/web/src/lib/provider/session.js
@@ -26,8 +26,7 @@ import { initialVersion, fetchGitHubInfo, setStars, setForks 
} from '@/lib/store
 import { oauthProviderFactory } from '@/lib/auth/providers/factory'
 
 import { to } from '../utils'
-import { getAuthConfigs, setAuthToken, setAuthUser } from '../store/auth'
-import { getAuthMeApi } from '../api/auth'
+import { getAuthConfigs, getAuthMe, setAuthToken, setAuthUser } from 
'../store/auth'
 
 const authProvider = {
   version: '',
@@ -98,12 +97,18 @@ const AuthProvider = ({ children }) => {
           router.push('/login')
         } else {
           dispatch(setAuthUser(sessionUser))
+          if (sessionUser) {
+            await dispatch(getAuthMe())
+          }
           goToMetalakeListPage()
         }
       } else if (authType === 'basic') {
         const tokenToUse = sessionStorage.getItem('accessToken')
 
         if (tokenToUse) {
+          dispatch(setAuthToken(tokenToUse))
+          await dispatch(getAuthMe())
+          dispatch(initialVersion())
           goToMetalakeListPage()
         } else {
           router.push('/login')
@@ -122,20 +127,8 @@ const AuthProvider = ({ children }) => {
 
         if (tokenToUse) {
           dispatch(setAuthToken(tokenToUse))
-
-          // Fetch server-resolved principal to ensure UI identity matches 
server-side
-          // identity derived from principalFields + principalMapper config
-          let authUser = user
-          try {
-            const [meErr, meRes] = await to(getAuthMeApi())
-            if (!meErr && meRes && meRes.principal) {
-              authUser = { ...user, name: meRes.principal }
-            }
-          } catch (e) {
-            // Fallback to OIDC profile if /api/authn/me is unavailable
-          }
-
-          authUser && dispatch(setAuthUser(authUser))
+          user && dispatch(setAuthUser(user))
+          await dispatch(getAuthMe())
           dispatch(initialVersion())
           goToMetalakeListPage()
         } else {
diff --git a/web-v2/web/src/lib/store/auth/index.js 
b/web-v2/web/src/lib/store/auth/index.js
index 1931679ba2..ef5a1fd57b 100644
--- a/web-v2/web/src/lib/store/auth/index.js
+++ b/web-v2/web/src/lib/store/auth/index.js
@@ -22,7 +22,7 @@ import toast from 'react-hot-toast'
 
 import { to, isProdEnv } from '@/lib/utils'
 
-import { getAuthConfigsApi, loginApi, basicLoginApi } from '@/lib/api/auth'
+import { getAuthConfigsApi, getAuthMeApi, loginApi, basicLoginApi } from 
'@/lib/api/auth'
 
 import { initialVersion } from '@/lib/store/sys'
 import { oauthProviderFactory } from '@/lib/auth/providers/factory'
@@ -33,7 +33,6 @@ export const getAuthConfigs = 
createAsyncThunk('auth/getAuthConfigs', async () =
   let oauthUrl = null
   let authType = null
   let anthEnable = null
-  let serviceAdmins = null
   const [err, res] = await to(getAuthConfigsApi())
 
   if (err || !res) {
@@ -45,14 +44,26 @@ export const getAuthConfigs = 
createAsyncThunk('auth/getAuthConfigs', async () =
   // ** get the first authenticator from the response. response example: 
"[simple, oauth]"
   authType = res['gravitino.authenticators'][0].trim()
   anthEnable = res['gravitino.authorization.enable']
-  serviceAdmins = res['gravitino.authorization.serviceAdmins']
 
   localStorage.setItem('oauthUrl', oauthUrl)
 
   // Persist authType for axios interceptor to avoid circular dependency with 
Redux store
   localStorage.setItem('authType', authType)
 
-  return { oauthUrl, authType, anthEnable, serviceAdmins, systemConfig: res }
+  return { oauthUrl, authType, anthEnable, systemConfig: res }
+})
+
+export const getAuthMe = createAsyncThunk('auth/getAuthMe', async () => {
+  const [err, res] = await to(getAuthMeApi())
+
+  if (err) {
+    throw err instanceof Error ? err : new Error(String(err))
+  }
+  if (!res) {
+    throw new Error('The authenticated user endpoint returned an empty 
response')
+  }
+
+  return res
 })
 
 export const refreshToken = createAsyncThunk('auth/refreshToken', async (data, 
{ getState, dispatch }) => {
@@ -91,6 +102,7 @@ export const loginAction = 
createAsyncThunk('auth/loginAction', async ({ params,
   localStorage.setItem('expiredIn', expires_in)
   dispatch(setAuthToken(access_token))
   dispatch(setExpiredIn(expires_in))
+  await dispatch(getAuthMe())
   await dispatch(initialVersion())
 
   router.push('/metalakes')
@@ -121,6 +133,7 @@ export const basicLoginAction = createAsyncThunk(
     sessionStorage.removeItem('expiredIn') // Basic auth does not have an 
expiration time
 
     dispatch(setAuthToken(basicToken))
+    await dispatch(getAuthMe())
     await dispatch(initialVersion())
     router.push('/metalakes')
 
@@ -239,7 +252,7 @@ export const authSlice = createSlice({
     expiredIn: typeof window !== 'undefined' ? 
localStorage.getItem('expiredIn') : null,
     intervalId: null,
     anthEnable: null,
-    serviceAdmins: null,
+    isServiceAdmin: false,
     systemConfig: null,
     authUser: null
   },
@@ -267,6 +280,7 @@ export const authSlice = createSlice({
         sessionStorage.setItem('simpleAuthUser', 
JSON.stringify(action.payload))
       } else {
         sessionStorage.removeItem('simpleAuthUser')
+        state.isServiceAdmin = false
       }
       state.authUser = action.payload
     }
@@ -276,9 +290,25 @@ export const authSlice = createSlice({
       state.oauthUrl = action.payload.oauthUrl
       state.authType = action.payload.authType
       state.anthEnable = action.payload.anthEnable
-      state.serviceAdmins = action.payload.serviceAdmins
       state.systemConfig = action.payload.systemConfig
     })
+    builder.addCase(getAuthMe.fulfilled, (state, action) => {
+      if (action.payload?.principal) {
+        const authUser = {
+          ...(state.authUser || {}),
+          name: action.payload.principal,
+          type: state.authUser?.type || 'user'
+        }
+
+        sessionStorage.setItem('simpleAuthUser', JSON.stringify(authUser))
+        state.authUser = authUser
+      }
+
+      state.isServiceAdmin = action.payload?.serviceAdmin === true
+    })
+    builder.addCase(getAuthMe.rejected, state => {
+      state.isServiceAdmin = false
+    })
     builder.addCase(refreshToken.fulfilled, (state, action) => {
       localStorage.setItem('accessToken', action.payload.token)
       localStorage.setItem('expiredIn', action.payload.expiredIn)
diff --git a/web-v2/web/src/lib/store/auth/index.test.js 
b/web-v2/web/src/lib/store/auth/index.test.js
new file mode 100644
index 0000000000..eef5ca58d0
--- /dev/null
+++ b/web-v2/web/src/lib/store/auth/index.test.js
@@ -0,0 +1,53 @@
+/*
+ * 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 { beforeEach, describe, expect, it } from 'vitest'
+import authReducer, { getAuthMe, setAuthUser } from '@/lib/store/auth'
+
+describe('auth store', () => {
+  beforeEach(() => {
+    localStorage.clear()
+    sessionStorage.clear()
+  })
+
+  it('stores the server-resolved principal and service-admin status', () => {
+    let state = authReducer(undefined, setAuthUser({ email: 
'[email protected]', name: 'token-user' }))
+
+    state = authReducer(state, getAuthMe.fulfilled({ principal: 'mapped-user', 
serviceAdmin: true }, 'request-id'))
+
+    expect(state.authUser).toEqual({
+      email: '[email protected]',
+      name: 'mapped-user',
+      type: 'user'
+    })
+    expect(state.isServiceAdmin).toBe(true)
+    
expect(JSON.parse(sessionStorage.getItem('simpleAuthUser'))).toEqual(state.authUser)
+  })
+
+  it('clears service-admin status when the lookup fails or the user logs out', 
() => {
+    let state = authReducer(undefined, getAuthMe.fulfilled({ principal: 
'admin', serviceAdmin: true }, 'request-id'))
+
+    state = authReducer(state, { type: getAuthMe.rejected.type })
+    expect(state.isServiceAdmin).toBe(false)
+
+    state = authReducer(state, getAuthMe.fulfilled({ principal: 'admin', 
serviceAdmin: true }, 'request-id'))
+    state = authReducer(state, setAuthUser(null))
+    expect(state.isServiceAdmin).toBe(false)
+  })
+})
diff --git a/web-v2/web/src/lib/utils/metalakePermissions.js 
b/web-v2/web/src/lib/utils/metalakePermissions.js
new file mode 100644
index 0000000000..6fb315df71
--- /dev/null
+++ b/web-v2/web/src/lib/utils/metalakePermissions.js
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+export const canCreateMetalake = (authorizationEnabled, isServiceAdmin) =>
+  authorizationEnabled === false || isServiceAdmin === true
diff --git a/web-v2/web/src/lib/utils/metalakePermissions.test.js 
b/web-v2/web/src/lib/utils/metalakePermissions.test.js
new file mode 100644
index 0000000000..20b899c954
--- /dev/null
+++ b/web-v2/web/src/lib/utils/metalakePermissions.test.js
@@ -0,0 +1,33 @@
+/*
+ * 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 { describe, expect, it } from 'vitest'
+import { canCreateMetalake } from './metalakePermissions'
+
+describe('canCreateMetalake', () => {
+  it.each([
+    ['an authorization-disabled deployment', false, false, true],
+    ['a service admin when authorization is enabled', true, true, true],
+    ['a non-service-admin when authorization is enabled', true, false, false],
+    ['a user while authorization configuration is loading', null, false, 
false],
+    ['a user before authorization configuration is available', undefined, 
false, false]
+  ])('returns the expected result for %s', (_, authorizationEnabled, 
isServiceAdmin, expected) => {
+    expect(canCreateMetalake(authorizationEnabled, 
isServiceAdmin)).toBe(expected)
+  })
+})

Reply via email to