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)
+ })
+})