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 e587933040 [#12558] feat(lance): Add namespace read authorization
framework (#12573)
e587933040 is described below
commit e587933040fab86a289ffb6f313b383da9f442a7
Author: Qi Yu <[email protected]>
AuthorDate: Thu Aug 27 22:19:57 2026 +0800
[#12558] feat(lance): Add namespace read authorization framework (#12573)
### What changes were proposed in this pull request?
Add namespace read authorization to Lance REST in auxiliary mode.
- Reuse the protocol-neutral metadata authorization pipeline introduced
by #12593.
- Resolve Lance namespace IDs from standard JAX-RS parameters to catalog
or schema authorization targets; root operations opt in explicitly.
- Use the shared `@AuthorizationExpression` annotation and shared probe
expressions. Schema existence probes also permit `CREATE_SCHEMA`,
matching Gravitino probe semantics.
- Filter unauthorized catalogs and schemas before pagination through a
dependency-free `LanceMetadataFilter` hook.
- Inject the configured metalake through HK2 instead of mutable
process-wide state.
- Return Lance-compatible 403, 400, and 500 responses for authorization
denial, invalid namespace IDs, and configuration failures.
- Keep authorization-disabled and standalone behavior unchanged.
Table operations remain out of scope and are covered by #12560, #12561,
and #12562.
Depends on: #12593
### Why are the changes needed?
Auxiliary-mode Lance REST executes metadata operations with the
authenticated caller identity, but namespace read endpoints did not
enforce Gravitino privileges. An authenticated user could list,
describe, and probe catalogs or schemas they were not allowed to access.
Fix: #12558
### Does this PR introduce _any_ user-facing change?
Yes. When `gravitino.authorization.enable` is true and Lance REST runs
as an auxiliary service, namespace read operations enforce Gravitino
authorization and listings only return metadata visible to the caller.
No new configuration is introduced.
### How was this patch tested?
- `./gradlew :lance:lance-common:check :lance:lance-rest-server:check
-PskipITs`
- `./gradlew :lance:lance-rest-server:test --tests
org.apache.gravitino.lance.integration.test.LanceNamespaceAuthorizationIT
-PskipDockerTests=false`
- Unit coverage includes catalog and schema target resolution, custom
delimiters, root handling, normal access, schema create-probe access,
denial precedence, caller validation, invalid and missing targets, error
mapping, filtering before pagination, no-op filtering, and null-filter
reset.
- End-to-end coverage includes direct allow/deny behavior, catalog and
schema list filtering, schema probes, and administrator access.
All tests were run with proxy environment variables disabled.
---
.../gravitino/lance/common/config/LanceConfig.java | 6 +
.../lance/common/ops/LanceMetadataFilter.java | 49 ++++++
.../lance/common/ops/NamespaceWrapper.java | 21 +++
.../GravitinoLanceNameSpaceOperations.java | 21 ++-
.../common/ops/gravitino/ObjectIdentifier.java | 40 ++++-
.../lance/common/config/TestLanceConfig.java | 4 +
.../apache/gravitino/lance/LanceRESTService.java | 37 +++-
.../LanceAuthorizationMetadataFilter.java | 74 ++++++++
...anceMetadataAuthorizationMethodInterceptor.java | 171 +++++++++++++++++++
.../LanceRESTAuthInterceptionService.java | 72 ++++++++
.../annotations/LanceRootNamespace.java | 29 ++++
.../service/rest/LanceNamespaceOperations.java | 17 ++
.../TestGravitinoLanceNamespaceListFiltering.java | 100 +++++++++++
.../test/LanceNamespaceAuthorizationIT.java | 182 ++++++++++++++++++++
...anceMetadataAuthorizationMethodInterceptor.java | 189 +++++++++++++++++++++
15 files changed, 998 insertions(+), 14 deletions(-)
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/config/LanceConfig.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/config/LanceConfig.java
index 4b23175606..d7579383bf 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/config/LanceConfig.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/config/LanceConfig.java
@@ -128,6 +128,12 @@ public class LanceConfig extends Config implements
OverwriteDefaultConfig {
return get(METALAKE_NAME);
}
+ /** Returns whether the Gravitino metalake is configured with a non-blank
name. */
+ public boolean isGravitinoMetalakeConfigured() {
+ String metalake = getGravitinoMetalake();
+ return metalake != null && !metalake.isBlank();
+ }
+
public String getGravitinoAuthType() {
return get(GRAVITINO_AUTH_TYPE);
}
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceMetadataFilter.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceMetadataFilter.java
new file mode 100644
index 0000000000..0d4b8b80a0
--- /dev/null
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/LanceMetadataFilter.java
@@ -0,0 +1,49 @@
+/*
+ * 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.lance.common.ops;
+
+import java.util.List;
+
+/** Filters Lance metadata before pagination; {@link #NOOP} keeps
authorization-disabled lists. */
+public interface LanceMetadataFilter {
+
+ /** A filter that returns every listed name unchanged. */
+ LanceMetadataFilter NOOP = new LanceMetadataFilter() {};
+
+ /**
+ * Filters the catalogs listed for the root namespace.
+ *
+ * @param catalogNames the catalog names to filter.
+ * @return the catalog names the current caller may see.
+ */
+ default List<String> filterCatalogs(List<String> catalogNames) {
+ return catalogNames;
+ }
+
+ /**
+ * Filters the schemas listed under a catalog.
+ *
+ * @param catalogName the catalog holding the schemas.
+ * @param schemaNames the schema names to filter.
+ * @return the schema names the current caller may see.
+ */
+ default List<String> filterSchemas(String catalogName, List<String>
schemaNames) {
+ return schemaNames;
+ }
+}
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/NamespaceWrapper.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/NamespaceWrapper.java
index b466cb7455..227eeed35e 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/NamespaceWrapper.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/NamespaceWrapper.java
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.lance.common.ops;
+import javax.annotation.Nullable;
import org.apache.gravitino.lance.common.config.LanceConfig;
public abstract class NamespaceWrapper {
@@ -27,6 +28,7 @@ public abstract class NamespaceWrapper {
private final boolean auxMode;
private volatile boolean initialized = false;
+ private volatile LanceMetadataFilter metadataFilter =
LanceMetadataFilter.NOOP;
private LanceNamespaceOperations namespaceOps;
private LanceTableOperations tableOps;
@@ -67,6 +69,25 @@ public abstract class NamespaceWrapper {
return config;
}
+ /**
+ * Sets the filter applied to listed metadata names before pagination.
+ *
+ * @param metadataFilter the filter to apply, {@code null} restores {@link
+ * LanceMetadataFilter#NOOP}.
+ */
+ public void setMetadataFilter(@Nullable LanceMetadataFilter metadataFilter) {
+ this.metadataFilter = metadataFilter == null ? LanceMetadataFilter.NOOP :
metadataFilter;
+ }
+
+ /**
+ * Returns the filter applied to listed metadata names before pagination.
+ *
+ * @return the configured filter, never {@code null}.
+ */
+ public LanceMetadataFilter metadataFilter() {
+ return metadataFilter;
+ }
+
/**
* Whether Lance REST runs as an auxiliary service embedded in the Gravitino
server.
*
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
index 2fc005be27..69207ba39a 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
@@ -47,6 +47,7 @@ import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NonEmptyCatalogException;
import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.exceptions.NonEmptySchemaException;
+import org.apache.gravitino.lance.common.ops.LanceMetadataFilter;
import org.apache.gravitino.lance.common.ops.LanceNamespaceOperations;
import org.lance.namespace.errors.InvalidInputException;
import org.lance.namespace.errors.LanceNamespaceException;
@@ -91,19 +92,26 @@ public class GravitinoLanceNameSpaceOperations implements
LanceNamespaceOperatio
Preconditions.checkArgument(
nsId.levels() <= 2, "Expected at most 2-level namespace but got: %s",
namespaceId);
+ // Unauthorized entries are removed before the page is cut, so pagination
stays consistent with
+ // what the caller is allowed to see.
+ LanceMetadataFilter metadataFilter = namespaceWrapper.metadataFilter();
List<String> namespaces;
switch (nsId.levels()) {
case 0:
namespaces =
- Arrays.stream(namespaceWrapper.listCatalogsInfo())
- .filter(namespaceWrapper::isLakehouseCatalog)
- .map(Catalog::name)
- .collect(Collectors.toList());
+ metadataFilter.filterCatalogs(
+ Arrays.stream(namespaceWrapper.listCatalogsInfo())
+ .filter(namespaceWrapper::isLakehouseCatalog)
+ .map(Catalog::name)
+ .collect(Collectors.toList()));
break;
case 1:
- Catalog catalog =
namespaceWrapper.loadAndValidateLakehouseCatalog(nsId.levelAtListPos(0));
- namespaces = Lists.newArrayList(namespaceWrapper.listSchemas(catalog));
+ String catalogName = nsId.levelAtListPos(0);
+ Catalog catalog =
namespaceWrapper.loadAndValidateLakehouseCatalog(catalogName);
+ namespaces =
+ metadataFilter.filterSchemas(
+ catalogName,
Lists.newArrayList(namespaceWrapper.listSchemas(catalog)));
break;
case 2:
@@ -121,6 +129,7 @@ public class GravitinoLanceNameSpaceOperations implements
LanceNamespaceOperatio
"Expected at most 2-level namespace but got: " + namespaceId);
}
+ namespaces = Lists.newArrayList(namespaces);
Collections.sort(namespaces);
PageUtil.Page page =
PageUtil.splitPage(namespaces, pageToken,
PageUtil.normalizePageSize(limit));
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/ObjectIdentifier.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/ObjectIdentifier.java
index af114f27bc..f5a5238a8c 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/ObjectIdentifier.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/ObjectIdentifier.java
@@ -25,8 +25,13 @@ import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
-/** Utility identifier parser for Lance namespace/table string IDs. */
-class ObjectIdentifier {
+/**
+ * Utility identifier parser for Lance namespace/table string IDs.
+ *
+ * <p>A Lance identifier is a single string whose levels are joined by a
delimiter, for example
+ * {@code catalog$schema}. An empty string denotes the root namespace.
+ */
+public class ObjectIdentifier {
private final List<String> levels;
@@ -34,7 +39,14 @@ class ObjectIdentifier {
this.levels = levels;
}
- static ObjectIdentifier of(String id, String delimiterRegex) {
+ /**
+ * Parses a Lance identifier.
+ *
+ * @param id the identifier, an empty string denotes the root namespace.
+ * @param delimiterRegex the regular expression matching the level delimiter.
+ * @return the parsed identifier.
+ */
+ public static ObjectIdentifier of(String id, String delimiterRegex) {
Preconditions.checkArgument(id != null, "Identifier cannot be null");
Preconditions.checkArgument(
StringUtils.isNotBlank(delimiterRegex), "Delimiter regex cannot be
blank");
@@ -50,15 +62,31 @@ class ObjectIdentifier {
return new ObjectIdentifier(parsedLevels);
}
- int levels() {
+ /**
+ * Returns the number of levels in the identifier.
+ *
+ * @return the number of levels, {@code 0} for the root namespace.
+ */
+ public int levels() {
return levels.size();
}
- String levelAtListPos(int index) {
+ /**
+ * Returns the level at the given position.
+ *
+ * @param index the zero-based position of the level.
+ * @return the level name at the given position.
+ */
+ public String levelAtListPos(int index) {
return levels.get(index);
}
- List<String> listStyleId() {
+ /**
+ * Returns the identifier levels as an immutable list.
+ *
+ * @return the identifier levels, from the outermost to the innermost.
+ */
+ public List<String> listStyleId() {
return Collections.unmodifiableList(levels);
}
}
diff --git
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/config/TestLanceConfig.java
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/config/TestLanceConfig.java
index 6544ca7a3a..bb237a5a70 100644
---
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/config/TestLanceConfig.java
+++
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/config/TestLanceConfig.java
@@ -55,7 +55,10 @@ public class TestLanceConfig {
LanceConfig lanceConfig = new LanceConfig(properties);
Assertions.assertEquals("http://localhost:8090",
lanceConfig.getNamespaceBackendUri());
Assertions.assertNull(lanceConfig.getGravitinoMetalake()); // No default,
must be configured
+ Assertions.assertFalse(lanceConfig.isGravitinoMetalakeConfigured());
+ lanceConfig = new
LanceConfig(ImmutableMap.of(LanceConfig.METALAKE_NAME.getKey(), " "));
+ Assertions.assertFalse(lanceConfig.isGravitinoMetalakeConfigured());
// Test custom values
properties =
ImmutableMap.of(
@@ -66,6 +69,7 @@ public class TestLanceConfig {
lanceConfig = new LanceConfig(properties);
Assertions.assertEquals("http://gravitino-server:8090",
lanceConfig.getNamespaceBackendUri());
Assertions.assertEquals("production", lanceConfig.getGravitinoMetalake());
+ Assertions.assertTrue(lanceConfig.isGravitinoMetalakeConfigured());
}
@Test
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
index 00a6241384..b073c5bde6 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
@@ -19,11 +19,14 @@
package org.apache.gravitino.lance;
import static
org.apache.gravitino.lance.common.config.LanceConfig.NAMESPACE_BACKEND;
+import static
org.apache.gravitino.lance.service.authorization.LanceRESTAuthInterceptionService.METALAKE_BINDING;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;
+import javax.inject.Singleton;
import javax.servlet.Servlet;
+import org.apache.gravitino.Configs;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.auxiliary.GravitinoAuxiliaryService;
import org.apache.gravitino.lance.common.config.LanceConfig;
@@ -31,6 +34,8 @@ import
org.apache.gravitino.lance.common.ops.LanceNamespaceBackend;
import org.apache.gravitino.lance.common.ops.NamespaceWrapper;
import org.apache.gravitino.lance.service.LanceHealthCheckPathMatcher;
import org.apache.gravitino.lance.service.LanceServiceIdentityFilter;
+import
org.apache.gravitino.lance.service.authorization.LanceAuthorizationMetadataFilter;
+import
org.apache.gravitino.lance.service.authorization.LanceRESTAuthInterceptionService;
import org.apache.gravitino.listener.EventBus;
import org.apache.gravitino.listener.api.event.EventSource;
import org.apache.gravitino.metrics.MetricsSystem;
@@ -40,6 +45,7 @@ import org.apache.gravitino.server.web.HttpAuditFilter;
import org.apache.gravitino.server.web.HttpServerMetricsSource;
import org.apache.gravitino.server.web.JettyServer;
import org.apache.gravitino.server.web.JettyServerConfig;
+import org.glassfish.hk2.api.InterceptionService;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.jackson.JacksonFeature;
import org.glassfish.jersey.server.ResourceConfig;
@@ -78,6 +84,24 @@ public class LanceRESTService implements
GravitinoAuxiliaryService {
this.lanceNamespace = loadNamespaceImpl(lanceConfig, auxMode);
+ // Metadata authorization relies on the Gravitino authorizer running in
the same process, so it
+ // is only applied when Lance REST is an auxiliary service of the
Gravitino server.
+ // The Gravitino configuration is only available in auxiliary mode, so it
is read behind the
+ // mode check.
+ boolean authorizationEnabled =
+ auxMode &&
GravitinoEnv.getInstance().config().get(Configs.ENABLE_AUTHORIZATION);
+ boolean enableMetadataAuthorization =
+ authorizationEnabled && lanceConfig.isGravitinoMetalakeConfigured();
+ String metalakeName = lanceConfig.getGravitinoMetalake();
+ if (authorizationEnabled && !enableMetadataAuthorization) {
+ // A missing metalake makes the Lance backend unusable, but it should
not prevent the main
+ // Gravitino server from starting. The backend reports the missing
setting when it is used.
+ LOG.warn("Lance REST metadata authorization is disabled because no
metalake is configured");
+ }
+ if (enableMetadataAuthorization) {
+ lanceNamespace.setMetadataFilter(new
LanceAuthorizationMetadataFilter(metalakeName));
+ }
+
ResourceConfig resourceConfig = new ResourceConfig();
resourceConfig.register(JacksonFeature.class);
resourceConfig.packages(LANCE_REST_SPEC_PACKAGE);
@@ -85,6 +109,14 @@ public class LanceRESTService implements
GravitinoAuxiliaryService {
new AbstractBinder() {
@Override
protected void configure() {
+ if (enableMetadataAuthorization) {
+ // Pass the metalake through HK2 constructor injection so
authorization code does not
+ // need mutable process-wide state.
+ bind(metalakeName).to(String.class).named(METALAKE_BINDING);
+ bind(LanceRESTAuthInterceptionService.class)
+ .to(InterceptionService.class)
+ .in(Singleton.class);
+ }
bind(lanceNamespace).to(NamespaceWrapper.class).ranked(1);
}
});
@@ -115,9 +147,10 @@ public class LanceRESTService implements
GravitinoAuxiliaryService {
server.addServlet(new HealthAliasServlet("/lance"), "/health.html");
LOG.info(
- "Initialized Lance REST service for backend {} in {} mode",
+ "Initialized Lance REST service for backend {} in {} mode, metadata
authorization {}",
lanceConfig.getNamespaceBackend(),
- auxMode ? "auxiliary" : "standalone");
+ auxMode ? "auxiliary" : "standalone",
+ enableMetadataAuthorization ? "enabled" : "disabled");
}
@Override
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationMetadataFilter.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationMetadataFilter.java
new file mode 100644
index 0000000000..2c59b25e38
--- /dev/null
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationMetadataFilter.java
@@ -0,0 +1,74 @@
+/*
+ * 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.lance.service.authorization;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.lance.common.ops.LanceMetadataFilter;
+import org.apache.gravitino.server.authorization.MetadataAuthzHelper;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Removes the catalogs and schemas the caller may not see from a Lance
listing. */
+public class LanceAuthorizationMetadataFilter implements LanceMetadataFilter {
+
+ private final String metalakeName;
+
+ /**
+ * Creates a filter bound to a metalake.
+ *
+ * @param metalakeName the metalake Lance REST is bound to.
+ */
+ public LanceAuthorizationMetadataFilter(String metalakeName) {
+ this.metalakeName = metalakeName;
+ }
+
+ @Override
+ public List<String> filterCatalogs(List<String> catalogNames) {
+ return filter(
+ catalogNames,
+ Entity.EntityType.CATALOG,
+ AuthorizationExpressionConstants.LOAD_CATALOG_AUTHORIZATION_EXPRESSION,
+ name -> NameIdentifierUtil.ofCatalog(metalakeName, name));
+ }
+
+ @Override
+ public List<String> filterSchemas(String catalogName, List<String>
schemaNames) {
+ return filter(
+ schemaNames,
+ Entity.EntityType.SCHEMA,
+
AuthorizationExpressionConstants.FILTER_SCHEMA_AUTHORIZATION_EXPRESSION,
+ name -> NameIdentifierUtil.ofSchema(metalakeName, catalogName, name));
+ }
+
+ private List<String> filter(
+ List<String> names,
+ Entity.EntityType entityType,
+ String expression,
+ Function<String, NameIdentifier> toIdentifier) {
+ NameIdentifier[] identifiers =
names.stream().map(toIdentifier).toArray(NameIdentifier[]::new);
+ NameIdentifier[] authorized =
+ MetadataAuthzHelper.filterByExpression(metalakeName, expression,
entityType, identifiers);
+ return
Arrays.stream(authorized).map(NameIdentifier::name).collect(Collectors.toList());
+ }
+}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
new file mode 100644
index 0000000000..f2c4aea48d
--- /dev/null
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java
@@ -0,0 +1,171 @@
+/*
+ * 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.lance.service.authorization;
+
+import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.QueryParam;
+import org.aopalliance.intercept.MethodInterceptor;
+import org.aopalliance.intercept.MethodInvocation;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.lance.common.ops.NamespaceWrapper;
+import org.apache.gravitino.lance.common.ops.gravitino.ObjectIdentifier;
+import org.apache.gravitino.lance.service.LanceExceptionMapper;
+import
org.apache.gravitino.lance.service.authorization.annotations.LanceRootNamespace;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.lance.namespace.errors.InvalidInputException;
+import org.lance.namespace.errors.LanceNamespaceException;
+import org.lance.namespace.errors.PermissionDeniedException;
+
+/** Resolves Lance namespace IDs and maps shared authorization failures to
Lance REST responses. */
+public class LanceMetadataAuthorizationMethodInterceptor
+ extends BaseMetadataAuthorizationMethodInterceptor implements
MethodInterceptor {
+
+ private static final int SCHEMA_NAMESPACE_LEVELS = 2;
+
+ private final String metalakeName;
+
+ /**
+ * Creates an interceptor for the metalake exposed by this Lance REST
service.
+ *
+ * @param metalakeName metalake exposed by the service
+ */
+ public LanceMetadataAuthorizationMethodInterceptor(String metalakeName) {
+ this.metalakeName = metalakeName;
+ }
+
+ @Override
+ public Object invoke(MethodInvocation methodInvocation) throws Throwable {
+ return authorizeMethod(
+ methodInvocation.getMethod(), methodInvocation.getArguments(),
methodInvocation::proceed);
+ }
+
+ @Override
+ protected AuthorizationTarget resolveAuthorizationTarget(
+ Method method, AuthorizationExpression annotation, Parameter[]
parameters, Object[] args) {
+ Optional<String> namespaceId = pathArgument(parameters, args, "id");
+ boolean rootNamespace =
method.isAnnotationPresent(LanceRootNamespace.class);
+ if (rootNamespace) {
+ if (namespaceId.isPresent()) {
+ throw new IllegalStateException(
+ "A Lance root operation must not declare a namespace ID target");
+ }
+ return new AuthorizationTarget(baseIdentifiers(),
Entity.EntityType.METALAKE);
+ }
+
+ // An absent ID is a programming error, not the root namespace. Root
operations must opt in
+ // explicitly so a forgotten annotation cannot silently weaken
authorization.
+ String targetId =
+ namespaceId.orElseThrow(
+ () ->
+ new IllegalStateException(
+ "An authorized Lance operation must declare
@PathParam(\"id\") or "
+ + "@LanceRootNamespace"));
+ String delimiter =
+ queryArgument(parameters, args, "delimiter")
+ .orElse(NamespaceWrapper.NAMESPACE_DELIMITER_DEFAULT);
+ ObjectIdentifier identifier = ObjectIdentifier.of(targetId,
Pattern.quote(delimiter));
+ if (identifier.levels() == 0 || identifier.levels() >
SCHEMA_NAMESPACE_LEVELS) {
+ throw unsupportedIdentifier(targetId);
+ }
+
+ Map<Entity.EntityType, NameIdentifier> identifiers = baseIdentifiers();
+ String catalogName = identifier.levelAtListPos(0);
+ identifiers.put(
+ Entity.EntityType.CATALOG, NameIdentifierUtil.ofCatalog(metalakeName,
catalogName));
+ if (identifier.levels() == SCHEMA_NAMESPACE_LEVELS) {
+ identifiers.put(
+ Entity.EntityType.SCHEMA,
+ NameIdentifierUtil.ofSchema(metalakeName, catalogName,
identifier.levelAtListPos(1)));
+ return new AuthorizationTarget(identifiers, Entity.EntityType.SCHEMA);
+ }
+ return new AuthorizationTarget(identifiers, Entity.EntityType.CATALOG);
+ }
+
+ @Override
+ protected boolean shouldSkipExpressionEvaluation(AuthorizationTarget target)
{
+ // The root has no privilege-bearing Lance object. The shared pipeline
still validates the
+ // caller, while the metadata filter removes catalogs that the caller may
not see.
+ return target.entityType() == Entity.EntityType.METALAKE;
+ }
+
+ @Override
+ protected boolean isExceptionPropagate(Exception exception) {
+ return exception instanceof LanceNamespaceException;
+ }
+
+ @Override
+ protected Object toErrorResponse(Method method, Object[] args, Throwable
throwable) {
+ String namespaceId = pathArgument(method.getParameters(), args,
"id").orElse("");
+ Exception exception;
+ if (throwable instanceof ForbiddenException) {
+ exception =
+ new PermissionDeniedException(
+ throwable.getMessage(), getStackTrace(throwable), namespaceId);
+ } else if (throwable instanceof Exception) {
+ exception = (Exception) throwable;
+ } else {
+ exception = new RuntimeException(throwable);
+ }
+ return LanceExceptionMapper.toRESTResponse(namespaceId, exception);
+ }
+
+ private Map<Entity.EntityType, NameIdentifier> baseIdentifiers() {
+ Map<Entity.EntityType, NameIdentifier> identifiers = new HashMap<>();
+ identifiers.put(Entity.EntityType.METALAKE,
NameIdentifierUtil.ofMetalake(metalakeName));
+ return identifiers;
+ }
+
+ private InvalidInputException unsupportedIdentifier(String namespaceId) {
+ return new InvalidInputException(
+ "Unsupported Lance namespace identifier: " + namespaceId, "",
namespaceId);
+ }
+
+ private static Optional<String> pathArgument(Parameter[] parameters,
Object[] args, String name) {
+ for (int i = 0; i < parameters.length; i++) {
+ PathParam annotation = parameters[i].getAnnotation(PathParam.class);
+ if (args[i] != null && annotation != null &&
name.equals(annotation.value())) {
+ return Optional.of(String.valueOf(args[i]));
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static Optional<String> queryArgument(
+ Parameter[] parameters, Object[] args, String name) {
+ for (int i = 0; i < parameters.length; i++) {
+ QueryParam annotation = parameters[i].getAnnotation(QueryParam.class);
+ if (args[i] != null && annotation != null &&
name.equals(annotation.value())) {
+ return Optional.of(String.valueOf(args[i]));
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java
new file mode 100644
index 0000000000..18b18cc51a
--- /dev/null
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.lance.service.authorization;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.inject.Named;
+import org.aopalliance.intercept.ConstructorInterceptor;
+import org.aopalliance.intercept.MethodInterceptor;
+import org.apache.gravitino.lance.service.rest.LanceNamespaceOperations;
+import org.glassfish.hk2.api.Descriptor;
+import org.glassfish.hk2.api.Filter;
+import org.glassfish.hk2.api.InterceptionService;
+
+/** Installs metadata authorization proxies for Lance REST resources. */
+public class LanceRESTAuthInterceptionService implements InterceptionService {
+
+ /** HK2 binding name for the metalake passed to the authorization
interceptor. */
+ public static final String METALAKE_BINDING = "lanceAuthorizationMetalake";
+
+ private static final Set<String> INTERCEPTED_CLASSES =
+ ImmutableSet.of(LanceNamespaceOperations.class.getName());
+
+ private final MethodInterceptor authorizationInterceptor;
+
+ /**
+ * Creates the interception service for one metalake.
+ *
+ * @param metalakeName metalake exposed by Lance REST
+ */
+ @Inject
+ public LanceRESTAuthInterceptionService(@Named(METALAKE_BINDING) String
metalakeName) {
+ this.authorizationInterceptor = new
LanceMetadataAuthorizationMethodInterceptor(metalakeName);
+ }
+
+ @Override
+ public Filter getDescriptorFilter() {
+ return (Descriptor descriptor) ->
INTERCEPTED_CLASSES.contains(descriptor.getImplementation());
+ }
+
+ @Override
+ public List<MethodInterceptor> getMethodInterceptors(Method method) {
+ return ImmutableList.of(authorizationInterceptor);
+ }
+
+ @Override
+ public List<ConstructorInterceptor>
getConstructorInterceptors(Constructor<?> constructor) {
+ return Collections.emptyList();
+ }
+}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/annotations/LanceRootNamespace.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/annotations/LanceRootNamespace.java
new file mode 100644
index 0000000000..6ecd5ba50e
--- /dev/null
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/annotations/LanceRootNamespace.java
@@ -0,0 +1,29 @@
+/*
+ * 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.lance.service.authorization.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Marks a Lance REST method that explicitly addresses the root namespace. */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LanceRootNamespace {}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
index b448e84295..fac831a252 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
@@ -19,6 +19,8 @@
package org.apache.gravitino.lance.service.rest;
import static
org.apache.gravitino.lance.common.ops.NamespaceWrapper.NAMESPACE_DELIMITER_DEFAULT;
+import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.CAN_ACCESS_METADATA;
+import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.PROBE_SCHEMA_AUTHORIZATION_EXPRESSION;
import com.codahale.metrics.annotation.ResponseMetered;
import com.codahale.metrics.annotation.Timed;
@@ -36,7 +38,9 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.gravitino.lance.common.ops.NamespaceWrapper;
import org.apache.gravitino.lance.service.LanceExceptionMapper;
+import
org.apache.gravitino.lance.service.authorization.annotations.LanceRootNamespace;
import org.apache.gravitino.metrics.MetricNames;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import org.lance.namespace.model.CreateNamespaceRequest;
import org.lance.namespace.model.CreateNamespaceResponse;
import org.lance.namespace.model.DescribeNamespaceResponse;
@@ -52,6 +56,14 @@ public class LanceNamespaceOperations {
private static final String ROOT_NAMESPACE_ID = "";
+ // Catalog probes use normal catalog access. Schema probes additionally
permit CREATE_SCHEMA,
+ // because clients commonly check existence immediately before creating a
schema.
+ private static final String PROBE_NAMESPACE_AUTHORIZATION_EXPRESSION =
+ CAN_ACCESS_METADATA
+ + " || (entityType == 'SCHEMA' && ("
+ + PROBE_SCHEMA_AUTHORIZATION_EXPRESSION
+ + "))";
+
private final NamespaceWrapper lanceNamespace;
@Inject
@@ -63,6 +75,7 @@ public class LanceNamespaceOperations {
@Path("/{id}/list")
@Timed(name = "list-namespaces." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "list-namespaces", absolute = true)
+ @AuthorizationExpression(expression = CAN_ACCESS_METADATA)
public Response listNamespaces(
@PathParam("id") String namespaceId,
@DefaultValue(NAMESPACE_DELIMITER_DEFAULT) @QueryParam("delimiter")
String delimiter,
@@ -75,6 +88,8 @@ public class LanceNamespaceOperations {
@Path("/list")
@Timed(name = "list-namespaces-root." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "list-namespaces-root", absolute = true)
+ @AuthorizationExpression(expression = CAN_ACCESS_METADATA)
+ @LanceRootNamespace
public Response listNamespacesOnRoot(
@DefaultValue(NAMESPACE_DELIMITER_DEFAULT) @QueryParam("delimiter")
String delimiter,
@QueryParam("page_token") String pageToken,
@@ -99,6 +114,7 @@ public class LanceNamespaceOperations {
@Path("/{id}/describe")
@Timed(name = "describe-namespaces." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "describe-namespaces", absolute = true)
+ @AuthorizationExpression(expression = CAN_ACCESS_METADATA)
public Response describeNamespace(
@PathParam("id") String namespaceId,
@DefaultValue(NAMESPACE_DELIMITER_DEFAULT) @QueryParam("delimiter")
String delimiter) {
@@ -161,6 +177,7 @@ public class LanceNamespaceOperations {
@Path("/{id}/exists")
@Timed(name = "namespace-exists." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "namespace-exists", absolute = true)
+ @AuthorizationExpression(expression =
PROBE_NAMESPACE_AUTHORIZATION_EXPRESSION)
public Response namespaceExists(
@PathParam("id") String namespaceId,
@DefaultValue(NAMESPACE_DELIMITER_DEFAULT) @QueryParam("delimiter")
String delimiter) {
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNamespaceListFiltering.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNamespaceListFiltering.java
new file mode 100644
index 0000000000..5ec17d1358
--- /dev/null
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNamespaceListFiltering.java
@@ -0,0 +1,100 @@
+/*
+ * 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.lance.common.ops.gravitino;
+
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.lance.common.ops.LanceMetadataFilter;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.lance.namespace.model.ListNamespacesResponse;
+import org.mockito.Mockito;
+
+/** Verifies that unauthorized namespaces never consume a slot in the returned
page. */
+class TestGravitinoLanceNamespaceListFiltering {
+
+ private static final String DELIMITER = Pattern.quote("$");
+ private static final String CATALOG = "b_catalog";
+
+ @Test
+ void testCatalogsAreFilteredBeforePagination() {
+ GravitinoLanceNamespaceWrapper wrapper =
Mockito.mock(GravitinoLanceNamespaceWrapper.class);
+ Catalog hidden = catalog("a_catalog");
+ Catalog visible = catalog(CATALOG);
+ Mockito.when(wrapper.listCatalogsInfo()).thenReturn(new Catalog[] {hidden,
visible});
+ Mockito.when(wrapper.isLakehouseCatalog(Mockito.any())).thenReturn(true);
+ // "a_catalog" sorts first, so it would fill the single-entry page if it
were filtered after
+ // pagination.
+ Mockito.doReturn(allowOnly(CATALOG)).when(wrapper).metadataFilter();
+
+ ListNamespacesResponse response =
+ new GravitinoLanceNameSpaceOperations(wrapper).listNamespaces("",
DELIMITER, null, 1);
+
+ Assertions.assertEquals(Set.of(CATALOG), response.getNamespaces());
+
+
Mockito.when(wrapper.metadataFilter()).thenReturn(LanceMetadataFilter.NOOP);
+ response =
+ new GravitinoLanceNameSpaceOperations(wrapper).listNamespaces("",
DELIMITER, null, 10);
+ Assertions.assertEquals(Set.of("a_catalog", CATALOG),
response.getNamespaces());
+ }
+
+ @Test
+ void testSchemasAreFilteredBeforePagination() {
+ GravitinoLanceNamespaceWrapper wrapper =
Mockito.mock(GravitinoLanceNamespaceWrapper.class);
+ Catalog catalog = catalog(CATALOG);
+
Mockito.when(wrapper.loadAndValidateLakehouseCatalog(CATALOG)).thenReturn(catalog);
+ Mockito.when(wrapper.listSchemas(catalog)).thenReturn(new String[]
{"a_schema", "b_schema"});
+ Mockito.doReturn(allowOnly("b_schema")).when(wrapper).metadataFilter();
+
+ ListNamespacesResponse response =
+ new GravitinoLanceNameSpaceOperations(wrapper).listNamespaces(CATALOG,
DELIMITER, null, 1);
+
+ Assertions.assertEquals(Set.of("b_schema"), response.getNamespaces());
+ }
+
+ @Test
+ void testNullFilterRestoresNoop() {
+ GravitinoLanceNamespaceWrapper wrapper = new
GravitinoLanceNamespaceWrapper();
+ wrapper.setMetadataFilter(allowOnly(CATALOG));
+ wrapper.setMetadataFilter(null);
+
+ Assertions.assertSame(LanceMetadataFilter.NOOP, wrapper.metadataFilter());
+ }
+
+ private Catalog catalog(String name) {
+ Catalog catalog = Mockito.mock(Catalog.class);
+ Mockito.when(catalog.name()).thenReturn(name);
+ return catalog;
+ }
+
+ private LanceMetadataFilter allowOnly(String allowedName) {
+ LanceMetadataFilter filter = Mockito.mock(LanceMetadataFilter.class);
+ Mockito.when(filter.filterCatalogs(Mockito.anyList()))
+ .thenAnswer(invocation -> retain(invocation.getArgument(0),
allowedName));
+ Mockito.when(filter.filterSchemas(Mockito.anyString(), Mockito.anyList()))
+ .thenAnswer(invocation -> retain(invocation.getArgument(1),
allowedName));
+ return filter;
+ }
+
+ private List<String> retain(List<String> names, String allowedName) {
+ return names.stream().filter(allowedName::equals).toList();
+ }
+}
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java
new file mode 100644
index 0000000000..309abd248e
--- /dev/null
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java
@@ -0,0 +1,182 @@
+/*
+ * 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.lance.integration.test;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObject;
+import org.apache.gravitino.authorization.SecurableObjects;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.lance.namespace.model.CreateNamespaceRequest;
+import org.lance.namespace.model.ListNamespacesResponse;
+
+/** Verifies namespace authorization and list filtering through auxiliary-mode
Lance REST. */
+public class LanceNamespaceAuthorizationIT extends BaseIT {
+
+ private static final String ADMIN = "lance_authz_admin";
+ private static final String USER = "lance_authz_user";
+ private static final String VISIBLE_CATALOG = "lance_authz_visible_catalog";
+ private static final String HIDDEN_CATALOG = "lance_authz_hidden_catalog";
+ private static final String VISIBLE_SCHEMA = "lance_authz_visible_schema";
+ private static final String HIDDEN_SCHEMA = "lance_authz_hidden_schema";
+ private static final String DELIMITER = ".";
+
+ private final HttpClient httpClient = HttpClient.newHttpClient();
+
+ @BeforeAll
+ public void startIntegrationTest() throws Exception {
+ ignoreLanceAuxRestService = false;
+ customConfigs.put(Configs.ENABLE_AUTHORIZATION.getKey(), "true");
+ customConfigs.put(Configs.SERVICE_ADMINS.getKey(), ADMIN);
+ customConfigs.put(Configs.AUTHENTICATORS.getKey(), "simple");
+ customConfigs.put("SimpleAuthUserName", ADMIN);
+ super.startIntegrationTest();
+
+ String metalakeName = getLanceRESTServerMetalakeName();
+ client.createMetalake(metalakeName, "Lance authorization tests", null);
+ GravitinoMetalake metalake = client.loadMetalake(metalakeName);
+ metalake.addUser(USER);
+ createNamespace(ADMIN, VISIBLE_CATALOG);
+ createNamespace(ADMIN, HIDDEN_CATALOG);
+ createNamespace(ADMIN, id(VISIBLE_CATALOG, VISIBLE_SCHEMA));
+ createNamespace(ADMIN, id(VISIBLE_CATALOG, HIDDEN_SCHEMA));
+
+ grant(
+ metalake,
+ "lance_authz_catalog_role",
+ SecurableObjects.ofCatalog(
+ VISIBLE_CATALOG, new
ArrayList<>(List.of(Privileges.UseCatalog.allow()))));
+ grant(
+ metalake,
+ "lance_authz_schema_role",
+ SecurableObjects.ofSchema(
+ SecurableObjects.ofCatalog(VISIBLE_CATALOG, new ArrayList<>()),
+ VISIBLE_SCHEMA,
+ new ArrayList<>(List.of(Privileges.UseSchema.allow()))));
+ }
+
+ @AfterAll
+ public void clean() throws Exception {
+ try {
+ if (client != null) {
+ client.dropMetalake(getLanceRESTServerMetalakeName(), true);
+ }
+ } finally {
+ super.stopIntegrationTest();
+ }
+ }
+
+ @Test
+ public void testNamespaceAuthorization() throws Exception {
+ assertStatus(403, post(USER, HIDDEN_CATALOG, "describe"));
+ assertStatus(200, post(USER, VISIBLE_CATALOG, "describe"));
+ assertStatus(403, post(USER, id(VISIBLE_CATALOG, HIDDEN_SCHEMA),
"exists"));
+ assertStatus(200, post(USER, id(VISIBLE_CATALOG, VISIBLE_SCHEMA),
"exists"));
+
+ List<String> catalogs = list(USER, "");
+ Assertions.assertTrue(catalogs.contains(VISIBLE_CATALOG));
+ Assertions.assertFalse(catalogs.contains(HIDDEN_CATALOG));
+ List<String> schemas = list(USER, VISIBLE_CATALOG);
+ Assertions.assertTrue(schemas.contains(VISIBLE_SCHEMA));
+ Assertions.assertFalse(schemas.contains(HIDDEN_SCHEMA));
+
+ Assertions.assertTrue(list(ADMIN, "").containsAll(List.of(VISIBLE_CATALOG,
HIDDEN_CATALOG)));
+ assertStatus(200, post(ADMIN, HIDDEN_CATALOG, "describe"));
+ }
+
+ private void grant(GravitinoMetalake metalake, String role, SecurableObject
object) {
+ metalake.createRole(role, new HashMap<>(), List.of(object));
+ metalake.grantRolesToUser(List.of(role), USER);
+ }
+
+ private String id(String... levels) {
+ return String.join(DELIMITER, levels);
+ }
+
+ private List<String> list(String user, String namespaceId) throws Exception {
+ String path =
+ namespaceId.isEmpty() ? "/v1/namespace/list" : "/v1/namespace/" +
namespaceId + "/list";
+ HttpResponse<String> response =
+ httpClient.send(request(user, path).GET().build(),
HttpResponse.BodyHandlers.ofString());
+ assertStatus(200, response);
+ return new ArrayList<>(
+ ObjectMapperProvider.objectMapper()
+ .readValue(response.body(), ListNamespacesResponse.class)
+ .getNamespaces());
+ }
+
+ private void createNamespace(String user, String namespaceId) throws
Exception {
+ CreateNamespaceRequest body = new CreateNamespaceRequest();
+ for (String level : namespaceId.split("\\" + DELIMITER)) {
+ body.addIdItem(level);
+ }
+ HttpRequest request =
+ request(user, "/v1/namespace/" + namespaceId + "/create")
+ .POST(
+ HttpRequest.BodyPublishers.ofString(
+
ObjectMapperProvider.objectMapper().writeValueAsString(body)))
+ .build();
+ assertStatus(200, httpClient.send(request,
HttpResponse.BodyHandlers.ofString()));
+ }
+
+ private HttpResponse<String> post(String user, String namespaceId, String
operation)
+ throws Exception {
+ HttpRequest request =
+ request(user, "/v1/namespace/" + namespaceId + "/" + operation)
+ .POST(HttpRequest.BodyPublishers.ofString("{}"))
+ .build();
+ return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ }
+
+ private HttpRequest.Builder request(String user, String path) {
+ String credentials =
+ Base64.getEncoder().encodeToString((user +
":dummy").getBytes(StandardCharsets.UTF_8));
+ return HttpRequest.newBuilder()
+ .uri(
+ URI.create(
+ String.format(
+ "http://localhost:%d/lance%s?delimiter=%s",
+ getLanceRESTServerPort(), path, DELIMITER)))
+ .header(
+ AuthConstants.HTTP_HEADER_AUTHORIZATION,
+ AuthConstants.AUTHORIZATION_BASIC_HEADER + credentials)
+ .header(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER, "ALL")
+ .header("Content-Type", "application/json");
+ }
+
+ private void assertStatus(int expected, HttpResponse<String> response) {
+ Assertions.assertEquals(expected, response.statusCode(), "Unexpected body:
" + response.body());
+ }
+}
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
new file mode 100644
index 0000000000..356c6089b0
--- /dev/null
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
@@ -0,0 +1,189 @@
+/*
+ * 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.lance.service.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.Set;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Response;
+import org.aopalliance.intercept.MethodInvocation;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
+import
org.apache.gravitino.lance.service.authorization.annotations.LanceRootNamespace;
+import org.apache.gravitino.lance.service.rest.LanceNamespaceOperations;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+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.lance.namespace.model.ErrorResponse;
+import org.mockito.MockedStatic;
+
+/** Tests the Lance-specific target resolution and response mapping around the
shared pipeline. */
+class TestLanceMetadataAuthorizationMethodInterceptor {
+
+ private static final String METALAKE = "test_metalake";
+ private static final String CATALOG = "test_catalog";
+ private static final String SCHEMA = "test_schema";
+ private static final String PROCEEDED = "PROCEEDED";
+
+ private GravitinoAuthorizer authorizer;
+ private LanceMetadataAuthorizationMethodInterceptor interceptor;
+ private MockedStatic<PrincipalUtils> principalUtils;
+ private MockedStatic<AuthorizationUtils> authorizationUtils;
+ private MockedStatic<GravitinoAuthorizerProvider> authorizerProvider;
+
+ @BeforeEach
+ void setUp() {
+ authorizer = mock(GravitinoAuthorizer.class);
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+ principalUtils = mockStatic(PrincipalUtils.class);
+ authorizationUtils = mockStatic(AuthorizationUtils.class);
+ authorizerProvider = mockStatic(GravitinoAuthorizerProvider.class);
+
+ principalUtils
+ .when(PrincipalUtils::getCurrentPrincipal)
+ .thenReturn(new UserPrincipal("tester"));
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
authorizerProvider.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+ when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+ when(authorizer.deny(any(), any(), any(), any(), any())).thenReturn(false);
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(false);
+ when(authorizer.findUnheldRoles(any(), any(), any(),
any())).thenReturn(Set.of());
+ interceptor = new LanceMetadataAuthorizationMethodInterceptor(METALAKE);
+ }
+
+ @AfterEach
+ void tearDown() {
+ authorizerProvider.close();
+ authorizationUtils.close();
+ principalUtils.close();
+ }
+
+ @Test
+ void testCreateSchemaPrivilegeAllowsProbeButNotDescribe() throws Throwable {
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.CREATE_SCHEMA);
+
+ assertEquals(
+ PROCEEDED,
+ interceptor.invoke(invocation(namespaceMethod("namespaceExists"),
CATALOG, "$")));
+ assertEquals(
+ PROCEEDED,
+ interceptor.invoke(
+ invocation(namespaceMethod("namespaceExists"), CATALOG + "." +
SCHEMA, ".")));
+
+ Object describe =
+ interceptor.invoke(
+ invocation(namespaceMethod("describeNamespace"), CATALOG + "." +
SCHEMA, "."));
+ assertErrorResponse(describe, Response.Status.FORBIDDEN);
+ }
+
+ @Test
+ void testDenyOverridesSchemaProbePrivileges() throws Throwable {
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.CREATE_SCHEMA);
+ when(authorizer.deny(any(), any(), any(), any(), any())).thenReturn(true);
+
+ Object result =
+ interceptor.invoke(
+ invocation(namespaceMethod("namespaceExists"), CATALOG + "$" +
SCHEMA, "$"));
+ assertErrorResponse(result, Response.Status.FORBIDDEN);
+ }
+
+ @Test
+ void testRootValidatesUserButSkipsPrivilegeExpression() throws Throwable {
+ Method rootMethod =
+ LanceNamespaceOperations.class.getMethod(
+ "listNamespacesOnRoot", String.class, String.class, Integer.class);
+ MethodInvocation invocation = invocation(rootMethod, "$", null, null);
+ assertEquals(PROCEEDED, interceptor.invoke(invocation));
+ verify(authorizer, never()).authorize(any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void testProtocolAndOperationFailuresUseLanceResponses() throws Throwable {
+ MethodInvocation invalid =
+ invocation(namespaceMethod("describeNamespace"), CATALOG + "$" +
SCHEMA + "$extra", "$");
+ assertErrorResponse(interceptor.invoke(invalid),
Response.Status.BAD_REQUEST);
+ verify(invalid, never()).proceed();
+
+ MethodInvocation empty = invocation(namespaceMethod("describeNamespace"),
"", "$");
+ assertErrorResponse(interceptor.invoke(empty),
Response.Status.BAD_REQUEST);
+
+ MethodInvocation missing =
+ invocation(TestOperations.class.getMethod("missingTarget",
String.class), "$");
+ assertErrorResponse(interceptor.invoke(missing),
Response.Status.INTERNAL_SERVER_ERROR);
+
+ MethodInvocation conflicting =
+ invocation(TestOperations.class.getMethod("conflictingRoot",
String.class), CATALOG);
+ assertErrorResponse(interceptor.invoke(conflicting),
Response.Status.INTERNAL_SERVER_ERROR);
+
+ MethodInvocation operation =
+ invocation(TestOperations.class.getMethod("unannotated"), new
Object[0]);
+ when(operation.proceed()).thenThrow(new IllegalArgumentException("bad
request"));
+ assertErrorResponse(interceptor.invoke(operation),
Response.Status.BAD_REQUEST);
+ }
+
+ private void allow(Privilege.Name... privileges) {
+ Set<Privilege.Name> allowed = Set.of(privileges);
+ when(authorizer.authorize(any(), any(), any(), any(), any()))
+ .thenAnswer(invocation -> allowed.contains(invocation.getArgument(3)));
+ }
+
+ private Method namespaceMethod(String name) throws NoSuchMethodException {
+ return LanceNamespaceOperations.class.getMethod(name, String.class,
String.class);
+ }
+
+ private MethodInvocation invocation(Method method, Object... args) throws
Throwable {
+ MethodInvocation invocation = mock(MethodInvocation.class);
+ when(invocation.getMethod()).thenReturn(method);
+ when(invocation.getArguments()).thenReturn(args);
+ when(invocation.proceed()).thenReturn(PROCEEDED);
+ return invocation;
+ }
+
+ private void assertErrorResponse(Object result, Response.Status
expectedStatus) {
+ Response response = assertInstanceOf(Response.class, result);
+ assertEquals(expectedStatus.getStatusCode(), response.getStatus());
+ assertInstanceOf(ErrorResponse.class, response.getEntity());
+ }
+
+ public static class TestOperations {
+ @AuthorizationExpression(expression = "CAN_ACCESS_METADATA")
+ public void missingTarget(@QueryParam("delimiter") String delimiter) {}
+
+ @AuthorizationExpression(expression = "CAN_ACCESS_METADATA")
+ @LanceRootNamespace
+ public void conflictingRoot(@PathParam("id") String namespaceId) {}
+
+ public void unannotated() {}
+ }
+}