This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new c5f76b2c22 [#13251] improvement(authz): Reuse request context during
list filtering (#13252)
c5f76b2c22 is described below
commit c5f76b2c22ca345261d465d514e726005ba3c78c
Author: Qi Yu <[email protected]>
AuthorDate: Mon Sep 21 09:04:39 2026 +0800
[#13251] improvement(authz): Reuse request context during list filtering
(#13252)
### What changes were proposed in this pull request?
Reuse entry authorization state for parent-scope checks and per-object
list filtering in read-only REST requests. A scoped binding is opened by
the interceptor, bound only for read methods, reused only for the same
principal instance and metalake, and cleared when the request completes.
Filter workers receive the context explicitly; mutation operations
retain independent contexts.
### Why are the changes needed?
Separate contexts repeat user and role-version lookups during one list
request. Reusing the context lets filtering use state already loaded by
entry authorization.
Fix: #13251
### Does this PR introduce _any_ user-facing change?
No API or configuration changes. Authorization semantics remain
unchanged.
### How was this patch tested?
139 targeted unit tests passed across server-common, server, and
iceberg-rest-server. Tests cover context reuse through REST
interceptors, parallel filtering with table denies, security-context
isolation, exception cleanup, and JCasbin SQL-prefetch reuse with
revalidation on the next request. Ran Spotless on the changed modules.
---
.../authorization/AuthorizationRequestContext.java | 8 +-
.../authorization/AuthorizationRequestScope.java | 117 +++++++++++++++++
.../server/authorization/MetadataAuthzHelper.java | 74 ++++++-----
...BaseMetadataAuthorizationMethodInterceptor.java | 12 ++
.../TestAuthorizationRequestScope.java | 90 +++++++++++++
.../jcasbin/TestJcasbinAuthorizer.java | 32 +++++
...BaseMetadataAuthorizationMethodInterceptor.java | 143 +++++++++++++++++++++
.../web/filter/GravitinoInterceptionService.java | 4 +-
.../filter/TestGravitinoInterceptionService.java | 50 +++++++
9 files changed, 493 insertions(+), 37 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
index dc30ae58d0..76c4b2fef7 100644
---
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -51,9 +51,11 @@ import org.apache.gravitino.utils.PrincipalUtils;
* <li>per-request role loading happens at most once via {@link
#loadRole(Runnable)}.
* </ul>
*
- * <p>Instances are not intended to outlive a request and are not reusable
across threads beyond the
- * request handling thread; the internal maps are {@link ConcurrentHashMap}
purely to tolerate any
- * incidental fan-out (e.g. async listeners) within the same request scope.
+ * <p>Instances must not outlive a request or be reused across principals,
active-role selections or
+ * metalakes. Entry authorization and list filtering of one read-only request
may share an instance:
+ * list workers receive it explicitly, which is why the internal maps are
{@link ConcurrentHashMap}.
+ * Role selection must be fixed before workers start, and a mutation must not
reuse decisions made
+ * before it.
*/
public class AuthorizationRequestContext {
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/AuthorizationRequestScope.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/AuthorizationRequestScope.java
new file mode 100644
index 0000000000..c309516f52
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/AuthorizationRequestScope.java
@@ -0,0 +1,117 @@
+/*
+ * 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.gravitino.server.authorization;
+
+import java.lang.reflect.Method;
+import java.security.Principal;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import javax.ws.rs.GET;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.utils.PrincipalUtils;
+
+/**
+ * Makes entry authorization state available to list filtering during a
synchronous read request.
+ *
+ * <p>The interceptor opens a scope around the REST method, binds the context
it built for entry
+ * authorization when the method is a read, and closes the scope when the
method returns. {@link
+ * #getOrCreate(String)} then hands that context to {@code
MetadataAuthzHelper.filterByExpression}
+ * on the same thread. Filter workers receive the context explicitly; this
thread-local scope is not
+ * inherited by them.
+ *
+ * <p>This is separate from {@link org.apache.gravitino.utils.RequestContext}
because it is bound to
+ * the intercepted method, not to the servlet request, and is closed together
with it.
+ */
+public final class AuthorizationRequestScope implements AutoCloseable {
+ private static final ThreadLocal<AuthorizationRequestScope> CURRENT = new
ThreadLocal<>();
+
+ @Nullable private Principal principal;
+ @Nullable private String metalake;
+ @Nullable private AuthorizationRequestContext context;
+
+ private AuthorizationRequestScope() {}
+
+ /**
+ * Opens the scope of one intercepted invocation, to be closed on the same
thread with
+ * try-with-resources.
+ *
+ * @return the new scope
+ */
+ public static AuthorizationRequestScope open() {
+ AuthorizationRequestScope scope = new AuthorizationRequestScope();
+ CURRENT.set(scope);
+ return scope;
+ }
+
+ /**
+ * Binds completed entry authorization to this scope when the method is a
read operation. Only
+ * reads may reuse entry decisions, because a mutation could invalidate them
before the list is
+ * filtered. Nothing is bound for other methods or when no metalake was
authorized.
+ *
+ * @param method the intercepted REST method
+ * @param metalakeIdent the authorized metalake, or null when entry
authorization had none
+ * @param context the entry authorization context
+ */
+ public void bindIfRead(
+ Method method, @Nullable NameIdentifier metalakeIdent,
AuthorizationRequestContext context) {
+ if (metalakeIdent != null && method.isAnnotationPresent(GET.class)) {
+ bind(metalakeIdent.name(), context);
+ }
+ }
+
+ /**
+ * Binds completed entry authorization for a read-only operation to this
scope.
+ *
+ * @param metalake the authorized metalake
+ * @param context the entry authorization context
+ */
+ public void bind(String metalake, AuthorizationRequestContext context) {
+ this.principal = PrincipalUtils.getCurrentPrincipal();
+ this.metalake = Objects.requireNonNull(metalake, "metalake");
+ this.context = Objects.requireNonNull(context, "context");
+ }
+
+ /**
+ * Returns the bound entry context when it was built for the current
principal instance and the
+ * given metalake, otherwise a fresh context. Principal identity is compared
on purpose: a context
+ * snapshots the principal's active roles when it is created, and principal
equality may ignore
+ * those.
+ *
+ * @param metalake the metalake being filtered
+ * @return the matching request context, or a new independent context
+ */
+ public static AuthorizationRequestContext getOrCreate(String metalake) {
+ AuthorizationRequestScope scope = CURRENT.get();
+ if (scope != null
+ && scope.context != null
+ && scope.principal == PrincipalUtils.getCurrentPrincipal()
+ && Objects.equals(scope.metalake, metalake)) {
+ return scope.context;
+ }
+ return new AuthorizationRequestContext();
+ }
+
+ /** Removes the scope when the invocation completes. */
+ @Override
+ public void close() {
+ CURRENT.remove();
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
index 0499fed142..3fd021ba75 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataAuthzHelper.java
@@ -291,8 +291,10 @@ public class MetadataAuthzHelper {
String metalake,
String expression,
Entity.EntityType entityType,
- NameIdentifier[] nameIdentifiers) {
- Principal principal = PrincipalUtils.getCurrentPrincipal();
+ NameIdentifier[] nameIdentifiers,
+ Principal principal,
+ GravitinoAuthorizer authorizer,
+ AuthorizationRequestContext requestContext) {
Map<String, List<ParentScopeAccessPath>> entityShortCircuits =
LIST_SHORT_CIRCUITS.get(entityType);
List<ParentScopeAccessPath> accessPaths =
@@ -327,9 +329,6 @@ public class MetadataAuthzHelper {
}
}
- GravitinoAuthorizer authorizer =
- GravitinoAuthorizerProvider.getInstance().getGravitinoAuthorizer();
- AuthorizationRequestContext requestContext = new
AuthorizationRequestContext();
Map<Entity.EntityType, NameIdentifier> metadataNames =
NameIdentifierUtil.splitNameIdentifier(metalake, entityType,
nameIdentifiers[0]);
@@ -415,45 +414,54 @@ public class MetadataAuthzHelper {
// per-object loop over every catalog in the metalake.
NameIdentifier[] nameIdentifiers =
Arrays.stream(entities).map(toNameIdentifier).toArray(NameIdentifier[]::new);
- if (enableAuthorization() && nameIdentifiers.length > 0) {
- if (METADATA_OBJECT_ENTITY_TYPES.contains(entityType)) {
- Arrays.stream(nameIdentifiers)
- .forEach(
- identifier ->
NameIdentifierUtil.checkMetadataObjectName(identifier, entityType));
- }
+ if (!enableAuthorization() || nameIdentifiers.length == 0) {
+ return entities;
+ }
+ if (METADATA_OBJECT_ENTITY_TYPES.contains(entityType)) {
+ Arrays.stream(nameIdentifiers)
+ .forEach(
+ identifier ->
NameIdentifierUtil.checkMetadataObjectName(identifier, entityType));
+ }
- String principalName = PrincipalUtils.getCurrentPrincipal().getName();
- if (allVisibleViaParentScope(metalake, expression, entityType,
nameIdentifiers)) {
- // A privilege granted at a parent scope (metalake/catalog/schema)
makes every object in
- // the list visible, and no object-level deny exists, so the
per-object authorization loop
- // is skipped entirely. See
AuthorizationExpressionConstants.*_LIST_PARENT_SCOPE_*.
- LOG.debug(
- "List authorization short-circuit HIT for principal {}, entity
type {} under metalake "
- + "{}: all {} listed object(s) are visible via a parent-scope
grant; skipping the "
- + "per-object authorization loop.",
- principalName,
- entityType,
- metalake,
- nameIdentifiers.length);
- return entities;
- }
+ Principal principal = PrincipalUtils.getCurrentPrincipal();
+ GravitinoAuthorizer authorizer =
+ GravitinoAuthorizerProvider.getInstance().getGravitinoAuthorizer();
+ AuthorizationRequestContext authorizationRequestContext =
+ AuthorizationRequestScope.getOrCreate(metalake);
+ if (allVisibleViaParentScope(
+ metalake,
+ expression,
+ entityType,
+ nameIdentifiers,
+ principal,
+ authorizer,
+ authorizationRequestContext)) {
+ // A privilege granted at a parent scope (metalake/catalog/schema) makes
every object in
+ // the list visible, and no object-level deny exists, so the per-object
authorization loop
+ // is skipped entirely. See
AuthorizationExpressionConstants.*_LIST_PARENT_SCOPE_*.
LOG.debug(
- "List authorization short-circuit MISS for principal {}, entity type
{} under metalake "
- + "{} ({} object(s)); falling back to the per-object
authorization loop.",
- principalName,
+ "List authorization short-circuit HIT for principal {}, entity type
{} under metalake "
+ + "{}: all {} listed object(s) are visible via a parent-scope
grant; skipping the "
+ + "per-object authorization loop.",
+ principal.getName(),
entityType,
metalake,
nameIdentifiers.length);
+ return entities;
}
+ LOG.debug(
+ "List authorization short-circuit MISS for principal {}, entity type
{} under metalake "
+ + "{} ({} object(s)); falling back to the per-object authorization
loop.",
+ principal.getName(),
+ entityType,
+ metalake,
+ nameIdentifiers.length);
preloadToCache(entityType, nameIdentifiers);
- GravitinoAuthorizer authorizer =
- GravitinoAuthorizerProvider.getInstance().getGravitinoAuthorizer();
- AuthorizationRequestContext authorizationRequestContext = new
AuthorizationRequestContext();
return doFilter(
expression,
entities,
- PrincipalUtils.getCurrentPrincipal(),
+ principal,
authorizer,
authorizationRequestContext,
(entity) -> {
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
index dd22014fb1..3d76832d86 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
@@ -31,6 +31,7 @@ import org.apache.gravitino.auth.ActiveRoles;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
@@ -222,6 +223,14 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
*/
protected final Object authorizeMethod(Method method, Object[] args,
MethodInvoker methodInvoker)
throws Throwable {
+ try (AuthorizationRequestScope scope = AuthorizationRequestScope.open()) {
+ return authorizeMethodInScope(method, args, methodInvoker, scope);
+ }
+ }
+
+ private Object authorizeMethodInScope(
+ Method method, Object[] args, MethodInvoker methodInvoker,
AuthorizationRequestScope scope)
+ throws Throwable {
try {
Parameter[] parameters = method.getParameters();
AuthorizationExpression expressionAnnotation =
@@ -316,6 +325,9 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
throw new ForbiddenException(notAuthzMessage);
}
}
+ // A skipped standard check authorized nothing that list filtering
could reuse.
+ scope.bindIfRead(
+ method, skipStandardCheck ? null : metalakeIdent,
authorizationRequestContext);
}
} catch (Exception ex) {
if (ex instanceof ForbiddenException || isExceptionPropagate(ex)) {
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestAuthorizationRequestScope.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestAuthorizationRequestScope.java
new file mode 100644
index 0000000000..5220b3485f
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestAuthorizationRequestScope.java
@@ -0,0 +1,90 @@
+/*
+ * 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.gravitino.server.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.ActiveRoles;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.junit.jupiter.api.Test;
+
+/** Tests security boundaries and lifetime of read-request context reuse. */
+public class TestAuthorizationRequestScope {
+ /** A scope must not share role loading or cached decisions across security
identities. */
+ @Test
+ public void testSecurityBoundaries() throws Exception {
+ UserPrincipal principal = new UserPrincipal("tester");
+ PrincipalUtils.doAs(
+ principal,
+ () -> {
+ AuthorizationRequestContext context = new
AuthorizationRequestContext();
+ try (AuthorizationRequestScope scope =
AuthorizationRequestScope.open()) {
+ scope.bind("metalake", context);
+ assertSame(context,
AuthorizationRequestScope.getOrCreate("metalake"));
+ assertNotSame(context,
AuthorizationRequestScope.getOrCreate("other"));
+ PrincipalUtils.doAs(
+ new UserPrincipal("other"),
+ () -> {
+ assertNotSame(context,
AuthorizationRequestScope.getOrCreate("metalake"));
+ return null;
+ });
+ // The same user with other active roles is an equal but distinct
principal instance,
+ // and must get a context that carries its own roles.
+ UserPrincipal assumed =
principal.withActiveRoles(ActiveRoles.of(List.of("reader")));
+ PrincipalUtils.doAs(
+ assumed,
+ () -> {
+ AuthorizationRequestContext isolated =
+ AuthorizationRequestScope.getOrCreate("metalake");
+ assertNotSame(context, isolated);
+ assertEquals(assumed.getActiveRoles(),
isolated.getActiveRoles());
+ return null;
+ });
+ }
+ assertNotSame(context,
AuthorizationRequestScope.getOrCreate("metalake"));
+ return null;
+ });
+ }
+
+ /** Asynchronous work must not inherit the bound context, and a closed scope
leaves nothing. */
+ @Test
+ public void testWorkerIsolationAndCleanup() throws Exception {
+ PrincipalUtils.doAs(
+ new UserPrincipal("tester"),
+ () -> {
+ AuthorizationRequestContext context = new
AuthorizationRequestContext();
+ try (AuthorizationRequestScope scope =
AuthorizationRequestScope.open()) {
+ scope.bind("metalake", context);
+ assertSame(context,
AuthorizationRequestScope.getOrCreate("metalake"));
+ assertNotSame(
+ context,
+ CompletableFuture.supplyAsync(
+ () ->
AuthorizationRequestScope.getOrCreate("metalake"))
+ .join());
+ }
+ assertNotSame(context,
AuthorizationRequestScope.getOrCreate("metalake"));
+ return null;
+ });
+ }
+}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
index 9316cdad38..f47e925bdd 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/jcasbin/TestJcasbinAuthorizer.java
@@ -23,6 +23,7 @@ import static
org.apache.gravitino.authorization.Privilege.Name.USE_SCHEMA;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -84,6 +85,7 @@ import org.apache.gravitino.meta.RoleEntity;
import org.apache.gravitino.meta.SchemaVersion;
import org.apache.gravitino.meta.UserEntity;
import org.apache.gravitino.server.ServerConfig;
+import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
import org.apache.gravitino.server.authorization.MetadataIdConverter;
import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
import org.apache.gravitino.storage.relational.mapper.GroupMetaMapper;
@@ -891,6 +893,36 @@ public class TestJcasbinAuthorizer {
assertFalse(doAuthorizeOwner(currentPrincipal));
}
+ /** Reusing entry state avoids another SQL prefetch even for a different
privilege check. */
+ @Test
+ public void testReadScopeReusesEntryRolePrefetch() throws Exception {
+ Principal principal = PrincipalUtils.getCurrentPrincipal();
+ RoleEntity role =
+ mockRoleInStore(ALLOW_ROLE_ID, "allowRole",
ImmutableList.of(getAllowSecurableObject()));
+ mockDirectUserRoles(role);
+ MetadataObject catalog = MetadataObjects.of(null, "testCatalog",
MetadataObject.Type.CATALOG);
+ AuthorizationRequestContext entryContext = new
AuthorizationRequestContext();
+ assertTrue(
+ jcasbinAuthorizer.authorize(principal, METALAKE, catalog, USE_CATALOG,
entryContext));
+ Mockito.clearInvocations(userMetaMapper, roleMetaMapper);
+
+ try (AuthorizationRequestScope scope = AuthorizationRequestScope.open()) {
+ scope.bind(METALAKE, entryContext);
+ AuthorizationRequestContext filterContext =
AuthorizationRequestScope.getOrCreate(METALAKE);
+ assertSame(entryContext, filterContext);
+ assertFalse(
+ jcasbinAuthorizer.authorize(principal, METALAKE, catalog,
SELECT_TABLE, filterContext));
+ verify(userMetaMapper, Mockito.never())
+ .batchGetAuthSubjectsForUser(anyString(), anyString(), anyList());
+ verify(roleMetaMapper, Mockito.never()).batchGetRoleUpdatedAt(any());
+ }
+
+ // A subsequent request must revalidate SQL versions, even with warm
shared role caches.
+ AuthorizationRequestContext nextContext =
AuthorizationRequestScope.getOrCreate(METALAKE);
+ assertTrue(jcasbinAuthorizer.authorize(principal, METALAKE, catalog,
USE_CATALOG, nextContext));
+ verify(userMetaMapper).batchGetAuthSubjectsForUser(eq(METALAKE),
eq(USERNAME), anyList());
+ }
+
@Test
public void testPrefetchRunsAfterOwnerUserInfoLookup() throws Exception {
Principal currentPrincipal = PrincipalUtils.getCurrentPrincipal();
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
index de3afcb917..d100e1d405 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
@@ -19,8 +19,10 @@
package org.apache.gravitino.server.web.filter;
import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.CAN_ACCESS_METADATA;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -38,7 +40,17 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.UserPrincipal;
@@ -48,13 +60,19 @@ import
org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.authorization.GravitinoAuthorizer;
import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import org.apache.gravitino.storage.relational.po.auth.UserUpdatedAt;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.PrincipalUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
/** Tests for {@link BaseMetadataAuthorizationMethodInterceptor}. */
@@ -295,6 +313,119 @@ public class
TestBaseMetadataAuthorizationMethodInterceptor {
assertSame(failure, interceptor.invoke(invocation));
}
+ /** Entry authorization, parent checks and worker filtering share one user
lookup. */
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testReadListReusesEntryContext(boolean hasDeny) throws Throwable
{
+ AtomicInteger userLoads = new AtomicInteger();
+ Set<AuthorizationRequestContext> contexts = ConcurrentHashMap.newKeySet();
+ AtomicBoolean perObjectLoopRan = new AtomicBoolean();
+ AtomicReference<AuthorizationRequestContext> entryContext = new
AtomicReference<>();
+ Function<String, Optional<UserUpdatedAt>> loadUser =
+ key -> {
+ userLoads.incrementAndGet();
+ return Optional.of(new UserUpdatedAt(1L, 1L));
+ };
+ try (MockedStatic<GravitinoEnv> envStatic =
mockStatic(GravitinoEnv.class)) {
+ GravitinoEnv env = mock(GravitinoEnv.class);
+ Config config = mock(Config.class);
+ envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+ when(env.config()).thenReturn(config);
+ when(config.get(Configs.ENABLE_AUTHORIZATION)).thenReturn(true);
+
when(config.get(Configs.GRAVITINO_AUTHORIZATION_THREAD_POOL_SIZE)).thenReturn(2);
+ principalUtils.when(() -> PrincipalUtils.doAs(any(),
any())).thenCallRealMethod();
+ authorizationUtils
+ .when(() -> AuthorizationUtils.checkCurrentUser(any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ AuthorizationRequestContext context =
invocation.getArgument(2);
+ entryContext.set(context);
+ context.computeUserInfoIfAbsent("metalake::tester", loadUser);
+ return null;
+ });
+ when(authorizer.authorize(any(), any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ AuthorizationRequestContext context =
invocation.getArgument(4);
+ contexts.add(context);
+ context.computeUserInfoIfAbsent("metalake::tester", loadUser);
+ return true;
+ });
+ when(authorizer.hasDenyPolicy(any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ contexts.add(invocation.getArgument(3));
+ return hasDeny;
+ });
+ when(authorizer.deny(any(), any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ MetadataObject object = invocation.getArgument(2);
+ contexts.add(invocation.getArgument(4));
+ if (object.type() == MetadataObject.Type.TABLE) {
+ perObjectLoopRan.set(true);
+ }
+ return hasDeny && object.name().equals("hidden");
+ });
+ NameIdentifier visible = NameIdentifier.of("metalake", "catalog",
"schema", "visible");
+ NameIdentifier hidden = NameIdentifier.of("metalake", "catalog",
"schema", "hidden");
+ TestInvocation invocation = invocation("listTables", null);
+ when(invocation.proceed())
+ .thenAnswer(
+ unused ->
+ MetadataAuthzHelper.filterByExpression(
+ "metalake",
+
AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION,
+ Entity.EntityType.TABLE,
+ new NameIdentifier[] {visible, hidden}));
+
+ Object result = new
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation);
+
+ assertArrayEquals(
+ hasDeny ? new NameIdentifier[] {visible} : new NameIdentifier[]
{visible, hidden},
+ (NameIdentifier[]) result);
+ assertEquals(Set.of(entryContext.get()), contexts);
+ assertEquals(1, userLoads.get());
+ assertEquals(hasDeny, perObjectLoopRan.get());
+ assertNotSame(entryContext.get(),
AuthorizationRequestScope.getOrCreate("metalake"));
+ }
+ }
+
+ /** An endpoint failure must not retain a request's permission cache on a
reused thread. */
+ @Test
+ public void testReadContextIsClearedAfterOperationFailure() throws Throwable
{
+ when(authorizer.authorize(any(), any(), any(), any(),
any())).thenReturn(true);
+ AtomicReference<AuthorizationRequestContext> context = new
AtomicReference<>();
+ TestInvocation invocation = invocation("listTables", null);
+ IllegalStateException failure = new IllegalStateException("list failed");
+ when(invocation.proceed())
+ .thenAnswer(
+ unused -> {
+ context.set(AuthorizationRequestScope.getOrCreate("metalake"));
+ throw failure;
+ });
+ assertSame(failure, new
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation));
+ assertNotSame(context.get(),
AuthorizationRequestScope.getOrCreate("metalake"));
+ }
+
+ /** Write operations must not expose pre-mutation authorization decisions to
later filtering. */
+ @Test
+ public void testWriteDoesNotReuseEntryContext() throws Throwable {
+ AtomicReference<AuthorizationRequestContext> entryContext = new
AtomicReference<>();
+ when(authorizer.authorize(any(), any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ entryContext.set(invocation.getArgument(4));
+ return true;
+ });
+ TestInvocation invocation = invocation("writeTables", null);
+ when(invocation.proceed())
+ .thenAnswer(unused ->
AuthorizationRequestScope.getOrCreate("metalake"));
+ Object result = new
TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation);
+ assertInstanceOf(AuthorizationRequestContext.class, result);
+ assertNotSame(entryContext.get(), result);
+ }
+
private static TestInvocation invocation(String methodName, Object result)
throws Throwable {
Method method = TestOperations.class.getDeclaredMethod(methodName);
TestInvocation invocation = mock(TestInvocation.class);
@@ -385,6 +516,18 @@ public class
TestBaseMetadataAuthorizationMethodInterceptor {
}
private static class TestOperations {
+ @GET
+ @AuthorizationExpression(expression = "CATALOG::USE_CATALOG")
+ private String listTables() {
+ return "unused";
+ }
+
+ @POST
+ @AuthorizationExpression(expression = "CATALOG::USE_CATALOG")
+ private String writeTables() {
+ return "unused";
+ }
+
@AuthorizationExpression(
expression = CAN_ACCESS_METADATA,
accessMetadataType = MetadataObject.Type.METALAKE)
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 1cd0306af4..1fd90c407c 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -51,6 +51,7 @@ import
org.apache.gravitino.exceptions.IllegalNameIdentifierException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
import org.apache.gravitino.lineage.source.rest.LineageOperations;
import
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
+import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
@@ -162,7 +163,7 @@ public class GravitinoInterceptionService implements
InterceptionService {
AuthorizationExpression expressionAnnotation =
method.getAnnotation(AuthorizationExpression.class);
- try {
+ try (AuthorizationRequestScope scope = AuthorizationRequestScope.open())
{
AuthorizationExecutor executor = null;
if (expressionAnnotation != null) {
String expression = expressionAnnotation.expression();
@@ -270,6 +271,7 @@ public class GravitinoInterceptionService implements
InterceptionService {
expressionAnnotation, metadataContext, method,
evaluatedExpression);
}
}
+ scope.bindIfRead(method, metalakeIdent, authorizationRequestContext);
}
return methodInvocation.proceed();
} catch (IllegalMetadataObjectException ex) {
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 35efcd1cfb..0044367ab1 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -23,6 +23,7 @@ import static
org.apache.gravitino.server.authorization.expression.Authorization
import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.TEST_CATALOG_CONNECTION_WITH_CHANGES_AUTHORIZATION_EXPRESSION;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -37,6 +38,7 @@ import java.security.Principal;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Response;
import org.aopalliance.intercept.MethodInterceptor;
@@ -67,6 +69,7 @@ import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.listener.EventBus;
import
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
import org.apache.gravitino.metalake.MetalakeManager;
+import org.apache.gravitino.server.authorization.AuthorizationRequestScope;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationFullName;
@@ -78,6 +81,7 @@ import org.apache.gravitino.server.web.rest.CatalogOperations;
import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
import org.apache.gravitino.server.web.rest.SchemaOperations;
import org.apache.gravitino.server.web.rest.SecretsProviderOperations;
+import org.apache.gravitino.server.web.rest.TableOperations;
import org.apache.gravitino.server.web.rest.ViewOperations;
import org.apache.gravitino.tag.TagDispatcher;
import org.apache.gravitino.utils.PrincipalUtils;
@@ -209,6 +213,52 @@ public class TestGravitinoInterceptionService {
}
}
+ /** The Gravitino list endpoint receives entry state and never leaks it into
the next request. */
+ @Test
+ public void testListTablesReusesEntryContextAndCleansUp() throws Throwable {
+ try (MockedStatic<PrincipalUtils> principals =
mockStatic(PrincipalUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> providers =
+ mockStatic(GravitinoAuthorizerProvider.class);
+ MockedStatic<AuthorizationUtils> authorization =
mockStatic(AuthorizationUtils.class)) {
+ UserPrincipal principal = new UserPrincipal("tester");
+
principals.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal);
+
principals.when(PrincipalUtils::getCurrentUserName).thenReturn(principal.getName());
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+
providers.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+ when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(true);
+ AtomicReference<AuthorizationRequestContext> entry = new
AtomicReference<>();
+ authorization
+ .when(() -> AuthorizationUtils.checkCurrentUser(any(), any(), any()))
+ .thenAnswer(
+ call -> {
+ entry.set(call.getArgument(2));
+ return null;
+ });
+ Method method =
+ TableOperations.class.getMethod("listTables", String.class,
String.class, String.class);
+ MethodInvocation invocation = mock(MethodInvocation.class);
+ when(invocation.getMethod()).thenReturn(method);
+ when(invocation.getArguments()).thenReturn(new Object[] {"metalake",
"catalog", "schema"});
+ when(invocation.proceed())
+ .thenAnswer(unused ->
AuthorizationRequestScope.getOrCreate("metalake"));
+ MethodInterceptor interceptor =
+ new
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+ Object first = interceptor.invoke(invocation);
+ Assertions.assertSame(entry.get(), first);
+ Assertions.assertNotSame(first,
AuthorizationRequestScope.getOrCreate("metalake"));
+ Object second = interceptor.invoke(invocation);
+ Assertions.assertSame(entry.get(), second);
+ Assertions.assertNotSame(first, second);
+ doThrow(new IllegalStateException("list
failed")).when(invocation).proceed();
+ try (Response failure = (Response) interceptor.invoke(invocation)) {
+ assertEquals(500, failure.getStatus());
+ }
+ Assertions.assertNotSame(entry.get(),
AuthorizationRequestScope.getOrCreate("metalake"));
+ }
+ }
+
@Test
public void testMetadataAuthorizationMethodInterceptor() throws Throwable {
try (MockedStatic<PrincipalUtils> principalUtilsMocked =
mockStatic(PrincipalUtils.class);