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 5bf0d8b113 [#12562] feat(lance): Add table mutation and deletion
authorization (#12696)
5bf0d8b113 is described below
commit 5bf0d8b1136110e4b5dcc5bb3a2a8b7298dc2985
Author: Qi Yu <[email protected]>
AuthorDate: Thu Sep 3 21:54:24 2026 +0800
[#12562] feat(lance): Add table mutation and deletion authorization (#12696)
### What changes were proposed in this pull request?
Authorizes the four Lance REST table endpoints that were still served
unchecked after #12559, #12560 and #12561: `deregister`, `drop`,
`drop_columns` and `alter_columns`.
- `drop_columns` and `alter_columns` require MODIFY_TABLE or ownership.
- `deregister` and `drop` require ownership of the table or of one of
its ancestors. MODIFY_TABLE alters a table but never removes it.
- Both expressions are guarded on the addressed entity type, so an
identifier that resolves to a catalog or a schema is denied instead of
being authorized as if it addressed a table.
`deregister` (which removes only the Gravitino metadata) and `drop`
(which also deletes the data) deliberately require the same privilege.
Neither the Gravitino core server nor Iceberg REST splits removal into
two levels, and inventing a third rule here would be a difference
callers have to learn for no benefit.
This is the last endpoint group of #9087, so with this PR the Lance REST
surface is fully authorized. The remaining sub-task is the documentation
one, #12574.
Fix: #12562
### Why are the changes needed?
With authorization enabled, any authenticated caller could drop any
table or rewrite any table's columns through Lance REST, regardless of
the privileges they hold on it.
### Does this PR introduce _any_ user-facing change?
Yes. With authorization enabled, column changes, deregister and drop are
authorized and denied requests return 403 before any metadata or storage
mutation. A table the caller may not remove is reported as forbidden
whether or not it exists, so these endpoints cannot be used to probe for
existence.
### How was this patch tested?
`TestLanceMetadataAuthorizationMethodInterceptor` and
`LanceTableAuthorizationIT` cover the two properties this change is
about: a caller without the privilege is rejected *before* the operation
runs, and a table the caller cannot see is indistinguishable from one
that does not exist.
- MODIFY_TABLE authorizes column changes and SELECT_TABLE does not; an
explicit deny overrides MODIFY_TABLE; MODIFY_TABLE and CREATE_TABLE
together are still not enough to remove a table, while ownership is.
- The ancestor-ownership branches are covered per level rather than by
making the caller own everything: owning the schema removes tables the
owner never created, owning a catalog or the metalake does too, and
schema ownership without USE_CATALOG removes nothing.
- Denied paths assert `verify(invocation, never()).proceed()`, which is
the direct form of "no mutation on denial". In the integration test the
allowed column changes are checked against the described columns (`[id,
value]` -> `[id, renamed]` -> `[id]`), so they are shown to reach the
metadata store rather than merely passing the interceptor.
- A denied response is checked not to contain the table location, and an
inaccessible table and a missing table both return 403 to the
unprivileged caller while the admin sees 200 and 404.
New `TestLanceRESTEndpointAuthorizationCoverage` guards the completeness
of the whole surface. The shared pipeline authorizes a method only when
it carries `@AuthorizationExpression`, so adding an endpoint without one
serves it unchecked and no behavioral test would notice, because the new
endpoint simply has no test of its own. The test scans the REST resource
package instead of listing endpoints, and asserts that every JAX-RS
endpoint declares an expression and that every resource declaring one is
actually intercepted. Health operations are the one exception and are
listed explicitly. I verified the test fails as intended by removing the
two new annotations:
```
Lance REST endpoints without @AuthorizationExpression are served unchecked:
[LanceTableOperations#deregisterTable, LanceTableOperations#dropTable]
```
`./gradlew :lance:lance-rest-server:build :lance:lance-common:build` —
202 tests, all passing.
---
.../LanceAuthorizationExpressions.java | 11 ++
.../lance/service/rest/LanceTableOperations.java | 6 +
.../test/LanceTableAuthorizationIT.java | 219 ++++++++++++++++++++-
...anceMetadataAuthorizationMethodInterceptor.java | 160 +++++++++++++++
...TestLanceRESTEndpointAuthorizationCoverage.java | 152 ++++++++++++++
5 files changed, 539 insertions(+), 9 deletions(-)
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationExpressions.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationExpressions.java
index a3d72bbb4a..0d40c6fab8 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationExpressions.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceAuthorizationExpressions.java
@@ -129,5 +129,16 @@ public final class LanceAuthorizationExpressions {
+
AuthorizationExpressionConstants.MODIFY_TABLE_AUTHORIZATION_EXPRESSION
+ ")";
+ /**
+ * Authorizes removing a table, whether the storage is deleted with it or
only the Gravitino
+ * metadata is. Both require ownership of the table or of one of its
ancestors, matching the
+ * Gravitino and Iceberg REST surfaces: MODIFY_TABLE alters a table but
never removes it.
+ */
+ public static final String DROP_TABLE_AUTHORIZATION_EXPRESSION =
+ """
+ entityType == 'TABLE' && (ANY(OWNER, METALAKE, CATALOG) ||
SCHEMA_OWNER_WITH_USE_CATALOG ||
+ ANY_USE_CATALOG && ANY_USE_SCHEMA && TABLE::OWNER)
+ """;
+
private LanceAuthorizationExpressions() {}
}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
index 1d51a65dbf..5eb2f731ed 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
@@ -23,6 +23,8 @@ import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_LOCAT
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_LOCATION_HEADER;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_PROPERTIES_PREFIX_HEADER;
import static
org.apache.gravitino.lance.service.authorization.LanceAuthorizationExpressions.CREATE_TABLE_AUTHORIZATION_EXPRESSION;
+import static
org.apache.gravitino.lance.service.authorization.LanceAuthorizationExpressions.DROP_TABLE_AUTHORIZATION_EXPRESSION;
+import static
org.apache.gravitino.lance.service.authorization.LanceAuthorizationExpressions.MODIFY_TABLE_AUTHORIZATION_EXPRESSION;
import static
org.apache.gravitino.lance.service.authorization.LanceAuthorizationExpressions.PROBE_TABLE_AUTHORIZATION_EXPRESSION;
import static
org.apache.gravitino.lance.service.authorization.LanceAuthorizationExpressions.READ_TABLE_AUTHORIZATION_EXPRESSION;
@@ -221,6 +223,7 @@ public class LanceTableOperations {
@Path("/deregister")
@Timed(name = "deregister-table." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "deregister-table", absolute = true)
+ @AuthorizationExpression(expression = DROP_TABLE_AUTHORIZATION_EXPRESSION)
public Response deregisterTable(
@PathParam("id") String tableId,
@QueryParam("delimiter") @DefaultValue("$") String delimiter,
@@ -263,6 +266,7 @@ public class LanceTableOperations {
@Path("/drop")
@Timed(name = "drop-table." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "drop-table", absolute = true)
+ @AuthorizationExpression(expression = DROP_TABLE_AUTHORIZATION_EXPRESSION)
public Response dropTable(
@PathParam("id") String tableId,
@QueryParam("delimiter") @DefaultValue("$") String delimiter,
@@ -281,6 +285,7 @@ public class LanceTableOperations {
@Path("/drop_columns")
@Timed(name = "drop-columns." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "drop-columns", absolute = true)
+ @AuthorizationExpression(expression = MODIFY_TABLE_AUTHORIZATION_EXPRESSION)
public Response dropColumns(
@PathParam("id") String tableId,
@QueryParam("delimiter") @DefaultValue("$") String delimiter,
@@ -304,6 +309,7 @@ public class LanceTableOperations {
@Path("/alter_columns")
@Timed(name = "alter-columns." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "alter-columns", absolute = true)
+ @AuthorizationExpression(expression = MODIFY_TABLE_AUTHORIZATION_EXPRESSION)
public Response alterColumns(
@PathParam("id") String tableId,
@QueryParam("delimiter") @DefaultValue("$") String delimiter,
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceTableAuthorizationIT.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceTableAuthorizationIT.java
index 1036465ae8..c7722b0523 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceTableAuthorizationIT.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceTableAuthorizationIT.java
@@ -28,36 +28,51 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
import org.apache.gravitino.Configs;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.authorization.Owner;
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.lance.common.utils.ArrowUtils;
+import org.apache.gravitino.lance.common.utils.LanceConstants;
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.junit.jupiter.api.io.TempDir;
+import org.lance.namespace.model.AlterTableDropColumnsRequest;
import org.lance.namespace.model.CreateNamespaceRequest;
import org.lance.namespace.model.DeclareTableRequest;
import org.lance.namespace.model.DescribeTableResponse;
import org.lance.namespace.model.ListTablesResponse;
import org.lance.namespace.model.RegisterTableRequest;
-/** Verifies that read-only Lance REST table operations are authorized and
filtered. */
+/** Verifies that Lance REST table operations are authorized and filtered. */
public class LanceTableAuthorizationIT extends BaseIT {
private static final String ADMIN = "lance_table_authz_admin";
private static final String READER = "lance_table_authz_reader";
private static final String PROBER = "lance_table_authz_prober";
+ private static final String MUTATOR = "lance_table_authz_mutator";
+ private static final String SCHEMA_OWNER = "lance_table_authz_schema_owner";
+ private static final String LONE_OWNER = "lance_table_authz_lone_owner";
private static final String CATALOG = "lance_table_authz_catalog";
private static final String SCHEMA = "lance_table_authz_schema";
// Tables created by the write tests live in their own schema, so the
listing tests keep asserting
// the exact contents of the read schema whatever order the tests run in.
private static final String WRITE_SCHEMA = "lance_table_authz_write_schema";
+ // Two schemas owned by users who created nothing in them, to exercise
ancestor ownership.
+ private static final String OWNED_SCHEMA = "lance_table_authz_owned_schema";
+ private static final String LONE_SCHEMA = "lance_table_authz_lone_schema";
// The hidden table sorts first so that a listing filtered after pagination
rather than before it
// would return an empty first page instead of the visible table.
@@ -65,6 +80,11 @@ public class LanceTableAuthorizationIT extends BaseIT {
private static final String VISIBLE_TABLE = "b_visible_table";
private static final String MISSING_TABLE = "c_missing_table";
private static final String PROBER_TABLE = "d_prober_table";
+ private static final String MUTABLE_TABLE = "e_mutable_table";
+ private static final String DEREGISTER_TABLE = "f_deregister_table";
+ private static final String DROP_TABLE = "g_drop_table";
+ private static final String OWNED_SCHEMA_TABLE = "h_owned_schema_table";
+ private static final String LONE_SCHEMA_TABLE = "i_lone_schema_table";
private static final String DELIMITER = ".";
@TempDir private static Path tempDir;
@@ -85,16 +105,31 @@ public class LanceTableAuthorizationIT extends BaseIT {
GravitinoMetalake metalake = client.loadMetalake(metalakeName);
metalake.addUser(READER);
metalake.addUser(PROBER);
+ metalake.addUser(MUTATOR);
+ metalake.addUser(SCHEMA_OWNER);
+ metalake.addUser(LONE_OWNER);
createNamespace(CATALOG);
createNamespace(id(CATALOG, SCHEMA));
createNamespace(id(CATALOG, WRITE_SCHEMA));
+ createNamespace(id(CATALOG, OWNED_SCHEMA));
+ createNamespace(id(CATALOG, LONE_SCHEMA));
registerTable(VISIBLE_TABLE);
registerTable(HIDDEN_TABLE);
+ createTable(WRITE_SCHEMA, MUTABLE_TABLE);
+ // Registered by the admin, so neither owner below owns the table itself.
+ assertStatus(
+ 200, register(ADMIN, OWNED_SCHEMA, OWNED_SCHEMA_TABLE, null,
location(OWNED_SCHEMA_TABLE)));
+ assertStatus(
+ 200, register(ADMIN, LONE_SCHEMA, LONE_SCHEMA_TABLE, null,
location(LONE_SCHEMA_TABLE)));
+ setSchemaOwner(metalake, OWNED_SCHEMA, SCHEMA_OWNER);
+ setSchemaOwner(metalake, LONE_SCHEMA, LONE_OWNER);
SecurableObject catalogScope = SecurableObjects.ofCatalog(CATALOG, new
ArrayList<>());
SecurableObject schemaScope =
SecurableObjects.ofSchema(catalogScope, SCHEMA, new ArrayList<>());
+ SecurableObject writeSchemaScope =
+ SecurableObjects.ofSchema(catalogScope, WRITE_SCHEMA, new
ArrayList<>());
// The reader may select exactly one table, so the other one must stay
invisible even though
// the reader can reach the schema holding it.
@@ -110,6 +145,15 @@ public class LanceTableAuthorizationIT extends BaseIT {
SecurableObjects.ofTable(
schemaScope,
VISIBLE_TABLE,
+ new ArrayList<>(List.of(Privileges.SelectTable.allow()))),
+ // Read access to the mutable table as well, so that denying a
column change to the
+ // reader can only be explained by the missing MODIFY_TABLE and
not by the reader
+ // being unable to reach the table at all.
+ SecurableObjects.ofSchema(
+ catalogScope, WRITE_SCHEMA, new
ArrayList<>(List.of(Privileges.UseSchema.allow()))),
+ SecurableObjects.ofTable(
+ writeSchemaScope,
+ MUTABLE_TABLE,
new ArrayList<>(List.of(Privileges.SelectTable.allow())))));
grant(
@@ -129,6 +173,36 @@ public class LanceTableAuthorizationIT extends BaseIT {
WRITE_SCHEMA,
new ArrayList<>(
List.of(Privileges.UseSchema.allow(),
Privileges.CreateTable.allow())))));
+
+ // The schema owner holds USE_CATALOG and nothing else: everything below
the catalog has to
+ // come from owning the schema. The lone owner holds no role at all, so it
owns a schema it
+ // cannot reach.
+ grant(
+ metalake,
+ "lance_table_authz_schema_owner_role",
+ SCHEMA_OWNER,
+ List.of(
+ SecurableObjects.ofCatalog(
+ CATALOG, new
ArrayList<>(List.of(Privileges.UseCatalog.allow())))));
+
+ // The mutator may change tables it does not own, which must not let it
remove them.
+ grant(
+ metalake,
+ "lance_table_authz_mutator_role",
+ MUTATOR,
+ List.of(
+ SecurableObjects.ofCatalog(
+ CATALOG, new
ArrayList<>(List.of(Privileges.UseCatalog.allow()))),
+ SecurableObjects.ofSchema(
+ catalogScope,
+ SCHEMA,
+ new ArrayList<>(
+ List.of(Privileges.UseSchema.allow(),
Privileges.ModifyTable.allow()))),
+ SecurableObjects.ofSchema(
+ catalogScope,
+ WRITE_SCHEMA,
+ new ArrayList<>(
+ List.of(Privileges.UseSchema.allow(),
Privileges.ModifyTable.allow())))));
}
@AfterAll
@@ -202,6 +276,98 @@ public class LanceTableAuthorizationIT extends BaseIT {
Assertions.assertEquals(location(HIDDEN_TABLE), describedLocation(ADMIN,
HIDDEN_TABLE));
}
+ @Test
+ public void testColumnMutationRequiresModifyTable() throws Exception {
+ // The reader holds USE_CATALOG, USE_SCHEMA and SELECT_TABLE on this exact
table, so it can
+ // read the table. Asserting that first is what makes the denials below
evidence that
+ // SELECT_TABLE does not authorize a column change, rather than evidence
that the reader
+ // cannot reach the table.
+ assertStatus(200, table(READER, WRITE_SCHEMA, MUTABLE_TABLE, "describe"));
+ assertStatus(403, dropColumns(READER, MUTABLE_TABLE, "value"));
+ assertStatus(403, alterColumns(READER, MUTABLE_TABLE, "value", "renamed"));
+ Assertions.assertEquals(List.of("id", "value"),
describedColumns(MUTABLE_TABLE));
+
+ // An inherited MODIFY_TABLE grant authorizes both mutations, and each
reaches the metadata
+ // store rather than merely passing the authorization interceptor.
+ assertStatus(200, alterColumns(MUTATOR, MUTABLE_TABLE, "value",
"renamed"));
+ Assertions.assertEquals(List.of("id", "renamed"),
describedColumns(MUTABLE_TABLE));
+ assertStatus(200, dropColumns(MUTATOR, MUTABLE_TABLE, "renamed"));
+ Assertions.assertEquals(List.of("id"), describedColumns(MUTABLE_TABLE));
+
+ assertStatus(404, dropColumns(MUTATOR, MISSING_TABLE, "value"));
+ }
+
+ @Test
+ public void testRemovingATableRequiresOwnership() throws Exception {
+ // MODIFY_TABLE alters a table but never removes it.
+ assertStatus(403, table(MUTATOR, VISIBLE_TABLE, "deregister"));
+ assertStatus(403, table(MUTATOR, VISIBLE_TABLE, "drop"));
+ // The denied requests left the table in place.
+ assertStatus(200, table(ADMIN, VISIBLE_TABLE, "describe"));
+
+ // Creating assigns ownership, so the creator may use either removal
operation.
+ assertStatus(
+ 200, register(PROBER, WRITE_SCHEMA, DEREGISTER_TABLE, null,
location(DEREGISTER_TABLE)));
+ assertStatus(200, register(PROBER, WRITE_SCHEMA, DROP_TABLE, null,
location(DROP_TABLE)));
+ assertStatus(200, table(PROBER, WRITE_SCHEMA, DEREGISTER_TABLE,
"deregister"));
+ assertStatus(200, table(PROBER, WRITE_SCHEMA, DROP_TABLE, "drop"));
+ assertStatus(404, table(ADMIN, WRITE_SCHEMA, DEREGISTER_TABLE,
"describe"));
+ assertStatus(404, table(ADMIN, WRITE_SCHEMA, DROP_TABLE, "describe"));
+ }
+
+ @Test
+ public void testSchemaOwnershipRemovesTablesTheOwnerDidNotCreate() throws
Exception {
+ // SCHEMA_OWNER_WITH_USE_CATALOG: the admin registered this table, so the
only thing the caller
+ // has is ownership of the schema holding it, plus USE_CATALOG.
+ assertStatus(200, table(SCHEMA_OWNER, OWNED_SCHEMA, OWNED_SCHEMA_TABLE,
"drop"));
+ assertStatus(404, table(ADMIN, OWNED_SCHEMA, OWNED_SCHEMA_TABLE,
"describe"));
+ }
+
+ @Test
+ public void testSchemaOwnershipWithoutUseCatalogRemovesNothing() throws
Exception {
+ // Ownership of the schema alone is not enough, the expression also
requires USE_CATALOG.
+ assertStatus(403, table(LONE_OWNER, LONE_SCHEMA, LONE_SCHEMA_TABLE,
"drop"));
+ assertStatus(403, table(LONE_OWNER, LONE_SCHEMA, LONE_SCHEMA_TABLE,
"deregister"));
+ assertStatus(403, dropColumns(LONE_OWNER, LONE_SCHEMA, LONE_SCHEMA_TABLE,
"value"));
+ assertStatus(200, table(ADMIN, LONE_SCHEMA, LONE_SCHEMA_TABLE,
"describe"));
+ }
+
+ @Test
+ public void testMutationConcealsTablesTheCallerMayNotSee() throws Exception {
+ // Both are forbidden for the reader, so the endpoint cannot be used to
probe for existence.
+ HttpResponse<String> denied = table(READER, HIDDEN_TABLE, "deregister");
+ assertStatus(403, denied);
+ Assertions.assertFalse(denied.body().contains(location(HIDDEN_TABLE)),
denied.body());
+ assertStatus(403, table(READER, MISSING_TABLE, "deregister"));
+ assertStatus(200, table(ADMIN, HIDDEN_TABLE, "describe"));
+ assertStatus(404, table(ADMIN, MISSING_TABLE, "deregister"));
+ }
+
+ private HttpResponse<String> dropColumns(String user, String tableName,
String column)
+ throws Exception {
+ return dropColumns(user, WRITE_SCHEMA, tableName, column);
+ }
+
+ private HttpResponse<String> dropColumns(
+ String user, String schema, String tableName, String column) throws
Exception {
+ AlterTableDropColumnsRequest body = new AlterTableDropColumnsRequest();
+ body.setColumns(List.of(column));
+ return send(user, "/v1/table/" + id(CATALOG, schema, tableName) +
"/drop_columns", body);
+ }
+
+ private HttpResponse<String> alterColumns(
+ String user, String tableName, String column, String renamedColumn)
throws Exception {
+ // Sent as raw JSON because the generated request model serializes the
rename field in a shape
+ // the server does not accept, which would fail with a 400 before
authorization runs.
+ // TODO: switch back to AlterTableAlterColumnsRequest once the
lance-namespace models
+ // serialize `rename` as a plain string, otherwise this test silently
stops exercising the
+ // request shape the models produce.
+ return sendRaw(
+ user,
+ "/v1/table/" + id(CATALOG, WRITE_SCHEMA, tableName) + "/alter_columns",
+
"{\"alterations\":[{\"path\":\"%s\",\"rename\":\"%s\"}]}".formatted(column,
renamedColumn));
+ }
+
private List<String> listTables(String user, int limit) throws Exception {
HttpRequest request =
request(user, "/v1/namespace/" + id(CATALOG, SCHEMA) + "/table/list",
"&limit=" + limit)
@@ -218,6 +384,12 @@ public class LanceTableAuthorizationIT extends BaseIT {
return tables;
}
+ private void setSchemaOwner(GravitinoMetalake metalake, String schemaName,
String user) {
+ MetadataObject schemaObject =
+ MetadataObjects.of(List.of(CATALOG, schemaName),
MetadataObject.Type.SCHEMA);
+ metalake.setOwner(schemaObject, user, Owner.Type.USER);
+ }
+
private void grant(
GravitinoMetalake metalake, String role, String user,
List<SecurableObject> objects) {
metalake.createRole(role, new HashMap<>(), objects);
@@ -244,6 +416,21 @@ public class LanceTableAuthorizationIT extends BaseIT {
assertStatus(200, register(ADMIN, SCHEMA, tableName, null,
location(tableName)));
}
+ private void createTable(String schemaName, String tableName) throws
Exception {
+ Schema schema =
+ new Schema(
+ List.of(
+ Field.nullable("id", new ArrowType.Int(32, true)),
+ Field.nullable("value", new ArrowType.Utf8())));
+ HttpRequest request =
+ request(ADMIN, "/v1/table/" + id(CATALOG, schemaName, tableName) +
"/create", "")
+ .setHeader("Content-Type", "application/vnd.apache.arrow.stream")
+ .setHeader(LanceConstants.LANCE_TABLE_LOCATION_HEADER,
location(tableName))
+
.POST(HttpRequest.BodyPublishers.ofByteArray(ArrowUtils.generateIpcStream(schema)))
+ .build();
+ assertStatus(200, httpClient.send(request,
HttpResponse.BodyHandlers.ofString()));
+ }
+
private HttpResponse<String> register(
String user, String schema, String tableName, String mode, String
location) throws Exception {
RegisterTableRequest body = new RegisterTableRequest();
@@ -262,11 +449,25 @@ public class LanceTableAuthorizationIT extends BaseIT {
}
private String describedLocation(String user, String tableName) throws
Exception {
- HttpResponse<String> response = table(user, tableName, "describe");
+ return describe(user, tableName).getLocation();
+ }
+
+ private List<String> describedColumns(String tableName) throws Exception {
+ return describe(ADMIN, WRITE_SCHEMA,
tableName).getSchema().getFields().stream()
+ .map(field -> field.getName())
+ .toList();
+ }
+
+ private DescribeTableResponse describe(String user, String tableName) throws
Exception {
+ return describe(user, SCHEMA, tableName);
+ }
+
+ private DescribeTableResponse describe(String user, String schemaName,
String tableName)
+ throws Exception {
+ HttpResponse<String> response = table(user, schemaName, tableName,
"describe");
assertStatus(200, response);
return ObjectMapperProvider.objectMapper()
- .readValue(response.body(), DescribeTableResponse.class)
- .getLocation();
+ .readValue(response.body(), DescribeTableResponse.class);
}
private HttpResponse<String> table(String user, String tableName, String
operation)
@@ -284,12 +485,12 @@ public class LanceTableAuthorizationIT extends BaseIT {
}
private HttpResponse<String> send(String user, String path, Object body)
throws Exception {
+ return sendRaw(user, path,
ObjectMapperProvider.objectMapper().writeValueAsString(body));
+ }
+
+ private HttpResponse<String> sendRaw(String user, String path, String body)
throws Exception {
HttpRequest request =
- request(user, path, "")
- .POST(
- HttpRequest.BodyPublishers.ofString(
-
ObjectMapperProvider.objectMapper().writeValueAsString(body)))
- .build();
+ request(user, path,
"").POST(HttpRequest.BodyPublishers.ofString(body)).build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
}
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
index 7b5ef686fd..517d74d770 100644
---
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
@@ -35,6 +35,7 @@ import javax.ws.rs.QueryParam;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import org.aopalliance.intercept.MethodInvocation;
+import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.authorization.GravitinoAuthorizer;
@@ -48,10 +49,14 @@ 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.AlterTableAlterColumnsRequest;
+import org.lance.namespace.model.AlterTableDropColumnsRequest;
import org.lance.namespace.model.CreateNamespaceRequest;
import org.lance.namespace.model.DeclareTableRequest;
+import org.lance.namespace.model.DeregisterTableRequest;
import org.lance.namespace.model.DescribeTableRequest;
import org.lance.namespace.model.DropNamespaceRequest;
+import org.lance.namespace.model.DropTableRequest;
import org.lance.namespace.model.ErrorResponse;
import org.lance.namespace.model.RegisterTableRequest;
import org.lance.namespace.model.TableExistsRequest;
@@ -381,6 +386,149 @@ class TestLanceMetadataAuthorizationMethodInterceptor {
assertEquals(PROCEEDED,
interceptor.invoke(createTableInvocation(tableId(), "overwrite")));
}
+ @Test
+ void testColumnMutationRequiresModifyTable() throws Throwable {
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA,
Privilege.Name.MODIFY_TABLE);
+ assertEquals(PROCEEDED,
interceptor.invoke(dropColumnsInvocation(tableId())));
+ assertEquals(PROCEEDED,
interceptor.invoke(alterColumnsInvocation(tableId())));
+
+ // Reading a table does not authorize changing its columns.
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA,
Privilege.Name.SELECT_TABLE);
+ MethodInvocation dropColumns = dropColumnsInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(dropColumns),
Response.Status.FORBIDDEN);
+ verify(dropColumns, never()).proceed();
+ MethodInvocation alterColumns = alterColumnsInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(alterColumns),
Response.Status.FORBIDDEN);
+ verify(alterColumns, never()).proceed();
+ }
+
+ @Test
+ void testExplicitDenyOverridesModifyTable() throws Throwable {
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA,
Privilege.Name.MODIFY_TABLE);
+ doAnswer(
+ invocation ->
+
Privilege.Name.MODIFY_TABLE.equals(invocation.<Privilege.Name>getArgument(3)))
+ .when(authorizer)
+ .deny(any(), any(), any(), any(), any());
+
+ MethodInvocation dropColumns = dropColumnsInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(dropColumns),
Response.Status.FORBIDDEN);
+ verify(dropColumns, never()).proceed();
+ MethodInvocation alterColumns = alterColumnsInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(alterColumns),
Response.Status.FORBIDDEN);
+ verify(alterColumns, never()).proceed();
+ }
+
+ @Test
+ void testRemovingATableRequiresOwnership() throws Throwable {
+ // MODIFY_TABLE alters a table but never removes it.
+ allow(
+ Privilege.Name.USE_CATALOG,
+ Privilege.Name.USE_SCHEMA,
+ Privilege.Name.MODIFY_TABLE,
+ Privilege.Name.CREATE_TABLE);
+ MethodInvocation drop = dropTableInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(drop), Response.Status.FORBIDDEN);
+ verify(drop, never()).proceed();
+ MethodInvocation deregister = deregisterTableInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(deregister),
Response.Status.FORBIDDEN);
+ verify(deregister, never()).proceed();
+
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(true);
+ assertEquals(PROCEEDED,
interceptor.invoke(dropTableInvocation(tableId())));
+ assertEquals(PROCEEDED,
interceptor.invoke(deregisterTableInvocation(tableId())));
+ }
+
+ @Test
+ void testAncestorOwnershipAuthorizesRemoval() throws Throwable {
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA);
+
+ // SCHEMA_OWNER_WITH_USE_CATALOG: owning the schema removes tables inside
it.
+ ownerOf(MetadataObject.Type.SCHEMA);
+ assertEquals(PROCEEDED,
interceptor.invoke(dropTableInvocation(tableId())));
+ assertEquals(PROCEEDED,
interceptor.invoke(deregisterTableInvocation(tableId())));
+
+ // ANY(OWNER, METALAKE, CATALOG): owning an ancestor above the schema is
enough on its own.
+ ownerOf(MetadataObject.Type.CATALOG);
+ assertEquals(PROCEEDED,
interceptor.invoke(dropTableInvocation(tableId())));
+ ownerOf(MetadataObject.Type.METALAKE);
+ assertEquals(PROCEEDED,
interceptor.invoke(deregisterTableInvocation(tableId())));
+ }
+
+ @Test
+ void testSchemaOwnershipWithoutUseCatalogCannotRemoveATable() throws
Throwable {
+ // SCHEMA::OWNER only counts through SCHEMA_OWNER_WITH_USE_CATALOG, which
also requires
+ // USE_CATALOG. Losing access to the catalog therefore withdraws the
removal too.
+ allow(Privilege.Name.USE_SCHEMA);
+ ownerOf(MetadataObject.Type.SCHEMA);
+
+ MethodInvocation drop = dropTableInvocation(tableId());
+ assertErrorResponse(interceptor.invoke(drop), Response.Status.FORBIDDEN);
+ verify(drop, never()).proceed();
+ }
+
+ @Test
+ void testAncestorOwnershipAuthorizesColumnMutation() throws Throwable {
+ allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA);
+
+ ownerOf(MetadataObject.Type.SCHEMA);
+ assertEquals(PROCEEDED,
interceptor.invoke(dropColumnsInvocation(tableId())));
+ ownerOf(MetadataObject.Type.CATALOG);
+ assertEquals(PROCEEDED,
interceptor.invoke(alterColumnsInvocation(tableId())));
+ }
+
+ @Test
+ void testMutationExpressionsRejectIdentifiersOfTheWrongDepth() throws
Throwable {
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(true);
+ allow(Privilege.Name.values());
+
+ // A schema identifier must not be authorized as if it addressed a table.
+ assertErrorResponse(
+ interceptor.invoke(dropTableInvocation(CATALOG + "$" + SCHEMA)),
Response.Status.FORBIDDEN);
+ assertErrorResponse(
+ interceptor.invoke(dropColumnsInvocation(CATALOG)),
Response.Status.FORBIDDEN);
+ }
+
+ private MethodInvocation dropTableInvocation(String tableId) throws
Throwable {
+ Method method =
+ LanceTableOperations.class.getMethod(
+ "dropTable", String.class, String.class, HttpHeaders.class,
DropTableRequest.class);
+ return invocation(method, tableId, "$", null, new DropTableRequest());
+ }
+
+ private MethodInvocation deregisterTableInvocation(String tableId) throws
Throwable {
+ Method method =
+ LanceTableOperations.class.getMethod(
+ "deregisterTable",
+ String.class,
+ String.class,
+ HttpHeaders.class,
+ DeregisterTableRequest.class);
+ return invocation(method, tableId, "$", null, new
DeregisterTableRequest());
+ }
+
+ private MethodInvocation dropColumnsInvocation(String tableId) throws
Throwable {
+ Method method =
+ LanceTableOperations.class.getMethod(
+ "dropColumns",
+ String.class,
+ String.class,
+ HttpHeaders.class,
+ AlterTableDropColumnsRequest.class);
+ return invocation(method, tableId, "$", null, new
AlterTableDropColumnsRequest());
+ }
+
+ private MethodInvocation alterColumnsInvocation(String tableId) throws
Throwable {
+ Method method =
+ LanceTableOperations.class.getMethod(
+ "alterColumns",
+ String.class,
+ String.class,
+ HttpHeaders.class,
+ AlterTableAlterColumnsRequest.class);
+ return invocation(method, tableId, "$", null, new
AlterTableAlterColumnsRequest());
+ }
+
private MethodInvocation createTableInvocation(String tableId, String mode)
throws Throwable {
Method method =
LanceTableOperations.class.getMethod(
@@ -461,6 +609,18 @@ class TestLanceMetadataAuthorizationMethodInterceptor {
return invocation(method, namespaceId, delimiter, new
DropNamespaceRequest());
}
+ /** Makes the caller the owner of exactly the given metadata levels, and of
nothing else. */
+ private void ownerOf(MetadataObject.Type... types) {
+ Set<MetadataObject.Type> owned = Set.of(types);
+ doAnswer(
+ invocation -> {
+ MetadataObject metadataObject = invocation.getArgument(2);
+ return metadataObject != null &&
owned.contains(metadataObject.type());
+ })
+ .when(authorizer)
+ .isOwner(any(), any(), any(), any());
+ }
+
private void allow(Privilege.Name... privileges) {
Set<Privilege.Name> allowed = Set.of(privileges);
// doAnswer, not when(...): a test that narrows the allowed privileges
calls this twice, and
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceRESTEndpointAuthorizationCoverage.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceRESTEndpointAuthorizationCoverage.java
new file mode 100644
index 0000000000..3c8a64ee60
--- /dev/null
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceRESTEndpointAuthorizationCoverage.java
@@ -0,0 +1,152 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.Path;
+import org.apache.gravitino.lance.service.rest.LanceHealthOperations;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import org.glassfish.hk2.api.Descriptor;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Guards the completeness of Lance REST authorization.
+ *
+ * <p>The shared interception pipeline authorizes a method only when it
carries {@link
+ * AuthorizationExpression}; a method without one is invoked unchecked. Adding
an endpoint therefore
+ * opens a hole that no behavioral test would notice, because the new endpoint
simply has no
+ * authorization test of its own. These tests scan the REST resource package
instead of listing the
+ * endpoints, so a new endpoint or a new resource class fails here until it is
authorized.
+ */
+class TestLanceRESTEndpointAuthorizationCoverage {
+
+ private static final String RESOURCE_PACKAGE =
"org.apache.gravitino.lance.service.rest";
+
+ /**
+ * Health checks answer before any metalake is known and are served to
unauthenticated liveness
+ * probes, so they deliberately carry no authorization annotation.
+ */
+ private static final Set<Class<?>> UNAUTHORIZED_RESOURCES =
Set.of(LanceHealthOperations.class);
+
+ @Test
+ void testEveryRESTEndpointDeclaresAnAuthorizationExpression() throws
Exception {
+ List<Class<?>> resources = authorizedResourceClasses();
+
+ List<String> unauthorized =
+ resources.stream()
+ .flatMap(resource -> Arrays.stream(resource.getDeclaredMethods()))
+ .filter(TestLanceRESTEndpointAuthorizationCoverage::isRestEndpoint)
+ .filter(method ->
!method.isAnnotationPresent(AuthorizationExpression.class))
+ .map(method -> method.getDeclaringClass().getSimpleName() + "#" +
method.getName())
+ .sorted()
+ .collect(Collectors.toList());
+
+ assertTrue(
+ unauthorized.isEmpty(),
+ "Lance REST endpoints without @AuthorizationExpression are served
unchecked: "
+ + unauthorized);
+ }
+
+ @Test
+ void testEveryAuthorizedResourceIsIntercepted() throws Exception {
+ LanceRESTAuthInterceptionService interceptionService =
+ new LanceRESTAuthInterceptionService("test_metalake");
+
+ for (Class<?> resource : authorizedResourceClasses()) {
+ assertTrue(
+
interceptionService.getDescriptorFilter().matches(descriptorOf(resource)),
+ resource.getName()
+ + " declares authorization expressions but is not intercepted,
so they are never "
+ + "evaluated");
+ }
+
+ // The exception is explicit rather than incidental: a resource is
unauthorized only because it
+ // is listed here, and listing it also keeps it out of the interception
service.
+ for (Class<?> resource : UNAUTHORIZED_RESOURCES) {
+ assertFalse(
+
interceptionService.getDescriptorFilter().matches(descriptorOf(resource)),
+ resource.getName() + " is intercepted but declares no authorization
expression");
+ }
+ }
+
+ private static boolean isRestEndpoint(Method method) {
+ // A JAX-RS HTTP method is any annotation that is itself meta-annotated
with @HttpMethod, which
+ // covers @GET, @POST, @PUT, @DELETE, @HEAD and @PATCH without listing
them.
+ return Arrays.stream(method.getAnnotations())
+ .anyMatch(annotation ->
annotation.annotationType().isAnnotationPresent(HttpMethod.class));
+ }
+
+ private static Descriptor descriptorOf(Class<?> resource) {
+ Descriptor descriptor = mock(Descriptor.class);
+ when(descriptor.getImplementation()).thenReturn(resource.getName());
+ return descriptor;
+ }
+
+ /** Returns the JAX-RS resource classes that must be authorized. */
+ private static List<Class<?>> authorizedResourceClasses() throws Exception {
+ List<Class<?>> resources =
+ scanPackage().stream()
+ .filter(clazz -> clazz.isAnnotationPresent(Path.class))
+ .filter(clazz -> !UNAUTHORIZED_RESOURCES.contains(clazz))
+ .collect(Collectors.toList());
+
+ // Without this the scan silently passing would make both tests vacuous.
+ assertFalse(resources.isEmpty(), "No JAX-RS resource found in " +
RESOURCE_PACKAGE);
+ return resources;
+ }
+
+ private static List<Class<?>> scanPackage() throws Exception {
+ String resourcePath = RESOURCE_PACKAGE.replace('.', '/');
+ List<Class<?>> classes = new ArrayList<>();
+ Enumeration<URL> roots =
+
Thread.currentThread().getContextClassLoader().getResources(resourcePath);
+ while (roots.hasMoreElements()) {
+ URL root = roots.nextElement();
+ if (!"file".equals(root.getProtocol())) {
+ continue;
+ }
+ File[] files =
+ new File(root.toURI())
+ .listFiles((directory, name) -> name.endsWith(".class") &&
!name.contains("$"));
+ if (files == null) {
+ continue;
+ }
+ for (File file : files) {
+ String simpleName =
+ file.getName().substring(0, file.getName().length() -
".class".length());
+ classes.add(Class.forName(RESOURCE_PACKAGE + "." + simpleName));
+ }
+ }
+ return classes;
+ }
+}