This is an automated email from the ASF dual-hosted git repository. github-actions[bot] pushed a commit to branch cherry-pick-f1a13e7c-to-branch-1.3 in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit 60c82530595da9171f55495654fc69a7cc5ba20b Author: MaSai <[email protected]> AuthorDate: Wed Nov 4 19:29:43 2026 +0800 [#14242] fix(iceberg-rest): Authorize registerTable overwrite and foreign metadata sources (#14482) ### What changes were proposed in this pull request? Authorize Iceberg REST `registerTable` more tightly in `RegisterTableAuthzHandler`: 1. **Destination first.** Before any metadata FileIO or catalog scan, evaluate create (plain register) or modify (overwrite) on the destination. Authorization completes in the handler (`authorizationCompleted()`), matching the Lance overwrite pattern without shared secondary-expression hooks. 2. **Foreign sources.** After destination authorization, load the metadata file, find live catalog tables that share the metadata file / UUID / table location, and require the load-table expression on each foreign source. Orphaned metadata (no live match, for example after drop) remains allowed. Matching lives in `RegisterTableSourceResolver`. Root namespace table listing is skipped for catalogs that reject it (for example Hive/JDBC). Non-`NoSuchTableException` failures while reading a candidate's current metadata propagate (fail-closed). 3. **Denial attribution.** `AuthorizationHandler.entityType()` (no-arg) tells the shared interceptor which object to attribute denials to; `RegisterTableAuthzHandler` binds the destination TABLE id at construction for overwrite audits. ### Why are the changes needed? `CREATE_TABLE` alone let a caller replace an existing table's metadata pointer, or register a new name that adopts metadata of a table they cannot read and become its owner. Doing destination authorization after metadata IO also let unauthorized callers open user-supplied paths and scan the catalog before a 403. Fixes #14242 Fixes #14441 ### Does this PR introduce _any_ user-facing change? Yes. 1. `registerTable` with `overwrite=true` requires ownership of the named table or `MODIFY_TABLE`. 2. Plain `registerTable` still requires the create-table expression, now checked before metadata IO. 3. `registerTable` that points at metadata still owned by another live table requires `SELECT_TABLE` / `MODIFY_TABLE` / ownership on that source table. ### Performance / availability note Every authorized register traverses namespaces/tables in this catalog and may load each unrelated table's current metadata to decide ownership. An unreadable unrelated table can therefore block registration. Keeping unknown ownership fail-closed is intentional; this patch does not add a global ownership index, and it does not restore a broad exception catch around source matching. ### How was this patch tested? ``` ./gradlew :iceberg:iceberg-rest-server:test -PskipITs \ --tests org.apache.gravitino.server.web.filter.TestRegisterTableAuthzHandler \ --tests org.apache.gravitino.iceberg.service.dispatcher.TestRegisterTableSourceResolver ./gradlew :server-common:test -PskipITs \ --tests org.apache.gravitino.server.web.filter.TestBaseMetadataAuthorizationMethodInterceptor ``` Also adds `IcebergNamespaceAuthorizationIT#testRegisterTableCannotAdoptUnreadableSource` (docker IT). --------- Co-authored-by: Cursor <[email protected]> # Conflicts: # server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java --- .../dispatcher/RegisterTableSourceResolver.java | 144 +++++++++ .../service/rest/IcebergNamespaceOperations.java | 3 +- .../annotations/IcebergAuthorizationMetadata.java | 1 + .../web/filter/CreateNamespaceAuthzHandler.java | 5 + ...bergMetadataAuthorizationMethodInterceptor.java | 7 +- .../server/web/filter/LoadTableAuthzHandler.java | 5 + .../server/web/filter/LoadViewAuthzHandler.java | 5 + .../web/filter/RegisterTableAuthzHandler.java | 211 +++++++++++++ .../server/web/filter/RenameTableAuthzHandler.java | 5 + .../server/web/filter/RenameViewAuthzHandler.java | 5 + .../test/IcebergNamespaceAuthorizationIT.java | 71 +++++ .../TestRegisterTableSourceResolver.java | 136 +++++++++ ...bergMetadataAuthorizationMethodInterceptor.java | 25 +- .../web/filter/TestRegisterTableAuthzHandler.java | 339 +++++++++++++++++++++ ...anceMetadataAuthorizationMethodInterceptor.java | 37 ++- .../annotations/AuthorizationExpression.java | 6 +- ...BaseMetadataAuthorizationMethodInterceptor.java | 23 +- ...BaseMetadataAuthorizationMethodInterceptor.java | 185 ++++++++++- 18 files changed, 1187 insertions(+), 26 deletions(-) diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/RegisterTableSourceResolver.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/RegisterTableSourceResolver.java new file mode 100644 index 0000000000..a4ef6aa76d --- /dev/null +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/RegisterTableSourceResolver.java @@ -0,0 +1,144 @@ +/* + * 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.iceberg.service.dispatcher; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; + +/** + * Resolves live catalog tables that share a register-table metadata file. + * + * <p>This class only discovers identities. Privilege checks belong in the Iceberg metadata + * authorization interceptor handlers. + */ +public final class RegisterTableSourceResolver { + + private RegisterTableSourceResolver() {} + + /** + * Finds tables other than {@code target} that share the given metadata file, UUID, or table + * location. + * + * @param catalogWrapper catalog to scan + * @param target register destination table + * @param metadataLocation metadata file from the register request + * @param metadata parsed metadata at that location + * @return matching live table identities + */ + public static List<TableIdentifier> findSourceTables( + IcebergCatalogWrapper catalogWrapper, + TableIdentifier target, + String metadataLocation, + TableMetadata metadata) { + List<TableIdentifier> matches = new ArrayList<>(); + String uuid = metadata.uuid(); + String tableLocation = normalizePath(metadata.location()); + String normalizedMeta = normalizePath(metadataLocation); + collectMatchingTables( + catalogWrapper, Namespace.empty(), target, uuid, tableLocation, normalizedMeta, matches); + return matches; + } + + private static void collectMatchingTables( + IcebergCatalogWrapper catalogWrapper, + Namespace parent, + TableIdentifier target, + String uuid, + String tableLocation, + String metadataLocation, + List<TableIdentifier> matches) { + List<Namespace> children; + List<TableIdentifier> tables; + try { + children = catalogWrapper.listNamespace(parent).namespaces(); + // Catalogs such as JDBC reject listing tables in the root namespace, and tables always live + // in a named namespace, so only list tables below the root. + tables = parent.isEmpty() ? List.of() : catalogWrapper.listTable(parent).identifiers(); + } catch (NoSuchNamespaceException e) { + // The namespace was dropped while scanning. + return; + } + for (Namespace child : children) { + collectMatchingTables( + catalogWrapper, child, target, uuid, tableLocation, metadataLocation, matches); + } + for (TableIdentifier identifier : tables) { + if (identifier.equals(target)) { + continue; + } + if (matchesSource(catalogWrapper, identifier, uuid, tableLocation, metadataLocation)) { + matches.add(identifier); + } + } + } + + private static boolean matchesSource( + IcebergCatalogWrapper catalogWrapper, + TableIdentifier identifier, + String uuid, + String tableLocation, + String metadataLocation) { + Optional<String> currentMeta = catalogWrapper.getTableMetadataLocation(identifier); + if (currentMeta.isPresent() && normalizePath(currentMeta.get()).equals(metadataLocation)) { + return true; + } + try { + TableMetadata existing = catalogWrapper.loadTableMetadata(identifier); + if (uuid != null && uuid.equals(existing.uuid())) { + return true; + } + return tableLocation != null + && !tableLocation.isEmpty() + && tableLocation.equals(normalizePath(existing.location())); + } catch (NoSuchTableException e) { + // Only a table that no longer exists is unrelated. Any other failure to read its metadata + // must not be mistaken for unrelated metadata, which would skip source authorization. + return false; + } + } + + /** Normalizes path strings so {@code file:/} and {@code file:///} compare equal. */ + public static String normalizePath(String path) { + if (path == null) { + return ""; + } + String trimmed = path.trim(); + if (trimmed.startsWith("file:")) { + String withoutScheme = trimmed.substring("file:".length()); + while (withoutScheme.startsWith("//")) { + withoutScheme = withoutScheme.substring(1); + } + if (!withoutScheme.startsWith("/")) { + withoutScheme = "/" + withoutScheme; + } + return "file:" + withoutScheme; + } + if (trimmed.endsWith("/")) { + return trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } +} diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java index 49da1f1643..886ffde502 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java @@ -321,7 +321,8 @@ public class IcebergNamespaceOperations { @AuthorizationMetadata(type = Entity.EntityType.CATALOG) @PathParam("prefix") String prefix, @AuthorizationMetadata(type = Entity.EntityType.SCHEMA) @Encoded() @PathParam("namespace") String namespace, - RegisterTableRequest registerTableRequest, + @IcebergAuthorizationMetadata(type = IcebergAuthorizationMetadata.RequestType.REGISTER_TABLE) + RegisterTableRequest registerTableRequest, @HeaderParam(IcebergTableOperations.X_ICEBERG_ACCESS_DELEGATION) String accessDelegation) { IcebergRESTUtils.validateRequest(registerTableRequest, "RegisterTableRequest"); boolean isCredentialVending = IcebergTableOperations.isCredentialVending(accessDelegation); diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java index 6488acb84f..2e2a3dba01 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/authorization/annotations/IcebergAuthorizationMetadata.java @@ -34,6 +34,7 @@ public @interface IcebergAuthorizationMetadata { RENAME_TABLE, RENAME_VIEW, CREATE_NAMESPACE, + REGISTER_TABLE, } /** * The type of the parameter to be used for authorization. diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java index 1d12ee4364..32e34a1b0c 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/CreateNamespaceAuthzHandler.java @@ -86,6 +86,11 @@ public class CreateNamespaceAuthzHandler implements AuthorizationHandler { return false; } + @Override + public EntityType entityType() { + return EntityType.SCHEMA; + } + private CreateNamespaceRequest extractRequest() { for (int i = 0; i < parameters.length; i++) { IcebergAuthorizationMetadata metadata = diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java index baeb09901d..2980614e5a 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java @@ -149,7 +149,10 @@ public class IcebergMetadataAuthorizationMethodInterceptor */ @Override protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { AuthorizationExpression authExpr = method.getAnnotation(AuthorizationExpression.class); for (Parameter parameter : parameters) { IcebergAuthorizationMetadata icebergMetadata = @@ -168,6 +171,8 @@ public class IcebergMetadataAuthorizationMethodInterceptor return Optional.of(new RenameViewAuthzHandler(parameters, args)); case CREATE_NAMESPACE: return Optional.of(new CreateNamespaceAuthzHandler(parameters, args)); + case REGISTER_TABLE: + return Optional.of(new RegisterTableAuthzHandler(parameters, args, nameIdentifierMap)); default: break; } diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java index ecbb2188ad..6b72ea2c0d 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadTableAuthzHandler.java @@ -110,6 +110,11 @@ public class LoadTableAuthzHandler implements AuthorizationHandler { return true; } + @Override + public EntityType entityType() { + return EntityType.TABLE; + } + /** * Perform TABLE-level authorization aligned with {@link * org.apache.gravitino.iceberg.service.rest.IcebergTableOperations#loadTable}: evaluate the diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java index 304046e270..ec0b9bbf64 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/LoadViewAuthzHandler.java @@ -98,6 +98,11 @@ public class LoadViewAuthzHandler implements AuthorizationHandler { return true; } + @Override + public EntityType entityType() { + return EntityType.VIEW; + } + /** * Perform VIEW-level authorization aligned with {@link * org.apache.gravitino.iceberg.service.rest.IcebergViewOperations#loadView}: evaluate the primary diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RegisterTableAuthzHandler.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RegisterTableAuthzHandler.java new file mode 100644 index 0000000000..209cbf5776 --- /dev/null +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RegisterTableAuthzHandler.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.server.web.filter; + +import static org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.LOAD_TABLE_AUTHORIZATION_EXPRESSION; +import static org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.MODIFY_TABLE_AUTHORIZATION_EXPRESSION; + +import java.lang.reflect.Parameter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import org.apache.gravitino.Entity.EntityType; +import org.apache.gravitino.NameIdentifier; +import org.apache.gravitino.authorization.AuthorizationRequestContext; +import org.apache.gravitino.exceptions.ForbiddenException; +import org.apache.gravitino.iceberg.service.CatalogWrapperForREST; +import org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext; +import org.apache.gravitino.iceberg.service.dispatcher.RegisterTableSourceResolver; +import org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata; +import org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator; +import org.apache.gravitino.server.web.filter.BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler; +import org.apache.gravitino.utils.HierarchicalSchemaUtil; +import org.apache.gravitino.utils.NameIdentifierUtil; +import org.apache.gravitino.utils.PrincipalUtils; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.rest.requests.RegisterTableRequest; + +/** + * Authorizes Iceberg REST {@code registerTable} requests that the method annotation alone cannot + * express. + * + * <p>Destination authorization runs first, before any metadata FileIO or catalog scan: overwrite + * uses the modify-table expression on the named table; a plain register uses the same create-table + * expression as {@code IcebergNamespaceOperations#registerTable}. Authorization then completes, so + * the shared interceptor does not re-evaluate the method expression. When the metadata file still + * belongs to another live table, the load-table expression on that table is required so {@code + * CREATE_TABLE} alone cannot adopt an unreadable table's metadata. + */ +@SuppressWarnings("FormatStringAnnotation") +public class RegisterTableAuthzHandler implements AuthorizationHandler { + + /** Same create expression as {@code IcebergNamespaceOperations#registerTable}. */ + private static final String CREATE_TABLE_EXPRESSION = + "ANY(OWNER, METALAKE, CATALOG) || " + + "SCHEMA_OWNER_WITH_USE_CATALOG || " + + "ANY_USE_CATALOG && ANY_USE_SCHEMA && ANY_CREATE_TABLE"; + + private static final Pattern SCHEMA_SPLIT = + Pattern.compile(Pattern.quote(HierarchicalSchemaUtil.schemaSeparator())); + + private final Parameter[] parameters; + private final Object[] args; + private final EntityType accessEntityType; + + /** + * Creates a handler for the register-table request arguments. + * + * @param parameters invoked method parameters + * @param args invoked method arguments + * @param nameIdentifierMap path identifiers; the destination TABLE id is bound here for denial + * attribution before {@link #process} + */ + public RegisterTableAuthzHandler( + Parameter[] parameters, Object[] args, Map<EntityType, NameIdentifier> nameIdentifierMap) { + this.parameters = parameters; + this.args = args; + RegisterTableRequest request = registerTableRequest(); + this.accessEntityType = request.overwrite() ? EntityType.TABLE : EntityType.SCHEMA; + NameIdentifier metalakeIdent = nameIdentifierMap.get(EntityType.METALAKE); + NameIdentifier catalogIdent = nameIdentifierMap.get(EntityType.CATALOG); + NameIdentifier schemaIdent = nameIdentifierMap.get(EntityType.SCHEMA); + if (metalakeIdent != null && catalogIdent != null && schemaIdent != null) { + nameIdentifierMap.put( + EntityType.TABLE, + NameIdentifierUtil.ofTable( + metalakeIdent.name(), catalogIdent.name(), schemaIdent.name(), request.name())); + } + } + + @Override + public void process(Map<EntityType, NameIdentifier> nameIdentifierMap) { + RegisterTableRequest request = registerTableRequest(); + + NameIdentifier metalakeIdent = nameIdentifierMap.get(EntityType.METALAKE); + NameIdentifier catalogIdent = nameIdentifierMap.get(EntityType.CATALOG); + NameIdentifier schemaIdent = nameIdentifierMap.get(EntityType.SCHEMA); + if (metalakeIdent == null || catalogIdent == null || schemaIdent == null) { + throw new ForbiddenException( + "Missing metalake, catalog, or schema context for register-table authorization"); + } + + String metalakeName = metalakeIdent.name(); + String catalog = catalogIdent.name(); + String schema = schemaIdent.name(); + String tableName = request.name(); + nameIdentifierMap.put( + EntityType.TABLE, NameIdentifierUtil.ofTable(metalakeName, catalog, schema, tableName)); + + requireCanAccessDestination(nameIdentifierMap, request.overwrite()); + authorizeMetadataSources(metalakeName, catalog, schema, tableName, request); + } + + @Override + public boolean authorizationCompleted() { + // Destination create/overwrite and any source checks are finished in process(). + return true; + } + + @Override + public EntityType entityType() { + return accessEntityType; + } + + private static void requireCanAccessDestination( + Map<EntityType, NameIdentifier> nameIdentifierMap, boolean overwrite) { + String expression = overwrite ? MODIFY_TABLE_AUTHORIZATION_EXPRESSION : CREATE_TABLE_EXPRESSION; + EntityType accessType = overwrite ? EntityType.TABLE : EntityType.SCHEMA; + boolean authorized = + new AuthorizationExpressionEvaluator(expression) + .evaluate( + nameIdentifierMap, + new HashMap<>(), + new AuthorizationRequestContext(), + Optional.of(accessType.name())); + if (!authorized) { + throw new ForbiddenException( + "User '%s' is not authorized to %s table '%s'", + PrincipalUtils.getCurrentUserName(), + overwrite ? "overwrite" : "register", + nameIdentifierMap.get(EntityType.TABLE)); + } + } + + private void authorizeMetadataSources( + String metalakeName, + String catalog, + String schema, + String tableName, + RegisterTableRequest request) { + CatalogWrapperForREST catalogWrapper = + IcebergRESTServerContext.getInstance().catalogWrapperManager().getCatalogWrapper(catalog); + TableMetadata metadata = + catalogWrapper.loadTableMetadataFromLocation(request.metadataLocation()); + Namespace targetNamespace = Namespace.of(SCHEMA_SPLIT.split(schema, -1)); + TableIdentifier target = TableIdentifier.of(targetNamespace, tableName); + List<TableIdentifier> sources = + RegisterTableSourceResolver.findSourceTables( + catalogWrapper, target, request.metadataLocation(), metadata); + for (TableIdentifier source : sources) { + requireCanLoadTable(metalakeName, catalog, source); + } + } + + private static void requireCanLoadTable( + String metalake, String catalogName, TableIdentifier source) { + String sourceSchema = + String.join(HierarchicalSchemaUtil.schemaSeparator(), source.namespace().levels()); + Map<EntityType, NameIdentifier> identifiers = new HashMap<>(); + identifiers.put(EntityType.METALAKE, NameIdentifierUtil.ofMetalake(metalake)); + identifiers.put(EntityType.CATALOG, NameIdentifierUtil.ofCatalog(metalake, catalogName)); + identifiers.put( + EntityType.SCHEMA, NameIdentifierUtil.ofSchema(metalake, catalogName, sourceSchema)); + identifiers.put( + EntityType.TABLE, + NameIdentifierUtil.ofTable(metalake, catalogName, sourceSchema, source.name())); + boolean authorized = + new AuthorizationExpressionEvaluator(LOAD_TABLE_AUTHORIZATION_EXPRESSION) + .evaluate( + identifiers, + new HashMap<>(), + new AuthorizationRequestContext(), + Optional.of(EntityType.TABLE.name())); + if (!authorized) { + throw new ForbiddenException( + "User '%s' is not authorized to register metadata from table '%s'", + PrincipalUtils.getCurrentUserName(), source); + } + } + + private RegisterTableRequest registerTableRequest() { + for (int i = 0; i < parameters.length; i++) { + IcebergAuthorizationMetadata metadata = + parameters[i].getAnnotation(IcebergAuthorizationMetadata.class); + if (metadata != null + && metadata.type() == IcebergAuthorizationMetadata.RequestType.REGISTER_TABLE) { + return (RegisterTableRequest) args[i]; + } + } + throw new ForbiddenException("RegisterTableRequest not found in parameters"); + } +} diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java index ef408e7048..9839c920a5 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameTableAuthzHandler.java @@ -117,6 +117,11 @@ public class RenameTableAuthzHandler implements AuthorizationHandler { return crossNamespaceRename; } + @Override + public EntityType entityType() { + return EntityType.TABLE; + } + /** * Validates authorization for cross-namespace renames following MySQL privilege model: - Requires * ownership on source table (equivalent to DROP privilege) - Requires CREATE_TABLE privilege on diff --git a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java index bc2f43b8a2..d5996b810f 100644 --- a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java +++ b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/RenameViewAuthzHandler.java @@ -116,6 +116,11 @@ public class RenameViewAuthzHandler implements AuthorizationHandler { return crossNamespaceRename; } + @Override + public EntityType entityType() { + return EntityType.VIEW; + } + /** * Validates authorization for cross-namespace view renames: ownership is required on the source * view, and {@code CREATE_VIEW} is required on the destination schema. Gravitino does not define diff --git a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java index e1968bcab4..799590add1 100644 --- a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java +++ b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/integration/test/IcebergNamespaceAuthorizationIT.java @@ -32,6 +32,9 @@ 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.iceberg.HasTableOperations; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ForbiddenException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -455,6 +458,58 @@ public class IcebergNamespaceAuthorizationIT extends IcebergAuthorizationIT { Assertions.assertDoesNotThrow(() -> sql("DESC TABLE %s", registeredTable)); } + @Test + void testRegisterTableCannotAdoptUnreadableSource() { + String sourceNamespace = "ns_register_secret"; + String destNamespace = "ns_register_mine"; + String sourceTable = "secret"; + String copyTable = "copy"; + + catalogClientWithAllPrivilege + .asSchemas() + .createSchema(sourceNamespace, "secret schema", new HashMap<>()); + catalogClientWithAllPrivilege + .asSchemas() + .createSchema(destNamespace, "caller schema", new HashMap<>()); + + // The super user owns the secret table; the normal user never receives privileges on it. + String[] sourceLevels = new String[] {sourceNamespace}; + createTableViaIRC(sourceLevels, sourceTable); + String metadataLocation = + ((HasTableOperations) + adminIcebergCatalog.loadTable( + TableIdentifier.of(Namespace.of(sourceLevels), sourceTable))) + .operations() + .current() + .metadataFileLocation(); + + revokeUserRoles(); + resetMetalakeAndCatalogOwner(); + grantUseCatalogRole(GRAVITINO_CATALOG_NAME); + metalakeClientWithAllPrivilege.setOwner( + MetadataObjects.of( + Arrays.asList(GRAVITINO_CATALOG_NAME, destNamespace), MetadataObject.Type.SCHEMA), + NORMAL_USER, + Owner.Type.USER); + grantUseSchemaRole(destNamespace); + + Assertions.assertThrowsExactly(ForbiddenException.class, () -> sql("USE %s", sourceNamespace)); + Assertions.assertThrowsExactly( + ForbiddenException.class, + () -> + sql( + "CALL rest.system.register_table(table => '%s.%s', metadata_file=> '%s')", + destNamespace, copyTable, metadataLocation)); + + grantUseSchemaRole(sourceNamespace); + grantSelectTableRole(sourceNamespace, sourceTable); + Assertions.assertDoesNotThrow( + () -> + sql( + "CALL rest.system.register_table(table => '%s.%s', metadata_file=> '%s')", + destNamespace, copyTable, metadataLocation)); + } + @Test void testComplexAuthorizations() { String namespace = "ns_complex_auth"; @@ -531,6 +586,22 @@ public class IcebergNamespaceAuthorizationIT extends IcebergAuthorizationIT { metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName), NORMAL_USER); } + /** Grants SELECT_TABLE privilege to the normal user for the specified table. */ + private void grantSelectTableRole(String schemaName, String tableName) { + String roleName = "selectTable_" + UUID.randomUUID(); + SecurableObject catalogObject = + SecurableObjects.ofCatalog(GRAVITINO_CATALOG_NAME, ImmutableList.of()); + SecurableObject schemaObject = + SecurableObjects.ofSchema(catalogObject, schemaName, ImmutableList.of()); + SecurableObject tableObject = + SecurableObjects.ofTable( + schemaObject, tableName, ImmutableList.of(Privileges.SelectTable.allow())); + + metalakeClientWithAllPrivilege.createRole( + roleName, new HashMap<>(), ImmutableList.of(tableObject)); + metalakeClientWithAllPrivilege.grantRolesToUser(ImmutableList.of(roleName), NORMAL_USER); + } + /** Grants CREATE_SCHEMA privilege to the normal user for the specified catalog. */ private void grantCreateSchemaRole(String catalogName) { String roleName = "createSchema_" + UUID.randomUUID(); diff --git a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestRegisterTableSourceResolver.java b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestRegisterTableSourceResolver.java new file mode 100644 index 0000000000..aa43395844 --- /dev/null +++ b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestRegisterTableSourceResolver.java @@ -0,0 +1,136 @@ +/* + * 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.iceberg.service.dispatcher; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.rest.responses.ListNamespacesResponse; +import org.apache.iceberg.rest.responses.ListTablesResponse; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests for {@link RegisterTableSourceResolver}. */ +class TestRegisterTableSourceResolver { + + private static final String META_LOC = "file:/warehouse/other/secret/metadata/v1.metadata.json"; + private static final String TABLE_LOC = "file:/warehouse/other/secret"; + + @Test + void testFindsForeignSourceByMetadataLocation() { + IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class); + TableIdentifier source = TableIdentifier.of("other", "secret"); + TableIdentifier target = TableIdentifier.of("mine", "copy"); + stubCatalogWithSource(catalog, source); + when(catalog.getTableMetadataLocation(source)).thenReturn(Optional.of(META_LOC)); + + List<TableIdentifier> found = + RegisterTableSourceResolver.findSourceTables(catalog, target, META_LOC, tableMetadata()); + + Assertions.assertEquals(List.of(source), found); + } + + @Test + void testIgnoresTargetItself() { + IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class); + TableIdentifier target = TableIdentifier.of("mine", "t"); + stubCatalogWithSource(catalog, target); + when(catalog.getTableMetadataLocation(target)).thenReturn(Optional.of(META_LOC)); + + List<TableIdentifier> found = + RegisterTableSourceResolver.findSourceTables(catalog, target, META_LOC, tableMetadata()); + + Assertions.assertTrue(found.isEmpty()); + } + + @Test + void testSkipsNamespaceDroppedDuringScan() { + IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class); + TableIdentifier source = TableIdentifier.of("other", "secret"); + stubCatalogWithSource(catalog, source); + when(catalog.listNamespace(source.namespace())) + .thenThrow(new NoSuchNamespaceException("Namespace does not exist: other")); + + List<TableIdentifier> found = + RegisterTableSourceResolver.findSourceTables( + catalog, TableIdentifier.of("mine", "copy"), META_LOC, tableMetadata()); + + Assertions.assertTrue(found.isEmpty()); + } + + @Test + void testUnreadableSourceMetadataPropagates() { + IcebergCatalogWrapper catalog = mock(IcebergCatalogWrapper.class); + TableIdentifier source = TableIdentifier.of("other", "secret"); + stubCatalogWithSource(catalog, source); + // The live table moved on to a corrupt current metadata file; the caller registers an older + // one. + when(catalog.getTableMetadataLocation(source)) + .thenReturn(Optional.of("file:/warehouse/other/secret/metadata/v2.metadata.json")); + when(catalog.loadTableMetadata(source)).thenThrow(new RuntimeException("corrupt metadata")); + + Assertions.assertThrows( + RuntimeException.class, + () -> + RegisterTableSourceResolver.findSourceTables( + catalog, TableIdentifier.of("mine", "copy"), META_LOC, tableMetadata())); + } + + @Test + void testNormalizePathUnifiesFileUriForms() { + Assertions.assertEquals( + "file:/warehouse/t", RegisterTableSourceResolver.normalizePath("file:///warehouse/t")); + Assertions.assertEquals( + "file:/warehouse/t", RegisterTableSourceResolver.normalizePath("file:/warehouse/t")); + } + + private static void stubCatalogWithSource(IcebergCatalogWrapper catalog, TableIdentifier source) { + Namespace ns = source.namespace(); + ListNamespacesResponse rootNs = mock(ListNamespacesResponse.class); + when(rootNs.namespaces()).thenReturn(List.of(ns)); + ListNamespacesResponse childNs = mock(ListNamespacesResponse.class); + when(childNs.namespaces()).thenReturn(Collections.emptyList()); + ListTablesResponse childTables = mock(ListTablesResponse.class); + when(childTables.identifiers()).thenReturn(List.of(source)); + + when(catalog.listNamespace(Namespace.empty())).thenReturn(rootNs); + when(catalog.listNamespace(ns)).thenReturn(childNs); + // Like Iceberg's HiveCatalog, the root namespace cannot list tables. + when(catalog.listTable(Namespace.empty())) + .thenThrow(new IllegalArgumentException("Missing database in namespace: ")); + when(catalog.listTable(ns)).thenReturn(childTables); + } + + private static TableMetadata tableMetadata() { + Schema schema = new Schema(Types.NestedField.optional(1, "id", Types.LongType.get())); + return TableMetadata.newTableMetadata( + schema, PartitionSpec.unpartitioned(), TABLE_LOC, Collections.emptyMap()); + } +} diff --git a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java index 63ade84bd6..2f38a0be18 100644 --- a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java +++ b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestIcebergMetadataAuthorizationMethodInterceptor.java @@ -279,7 +279,10 @@ public class TestIcebergMetadataAuthorizationMethodInterceptor { new IcebergMetadataAuthorizationMethodInterceptor() { @Override protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { return Optional.of( new AuthorizationHandler() { @Override @@ -292,6 +295,11 @@ public class TestIcebergMetadataAuthorizationMethodInterceptor { public boolean authorizationCompleted() { return false; } + + @Override + public Entity.EntityType entityType() { + return Entity.EntityType.TABLE; + } }); } }; @@ -404,7 +412,10 @@ public class TestIcebergMetadataAuthorizationMethodInterceptor { new IcebergMetadataAuthorizationMethodInterceptor() { @Override protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { return Optional.of( new AuthorizationHandler() { @Override @@ -417,6 +428,11 @@ public class TestIcebergMetadataAuthorizationMethodInterceptor { public boolean authorizationCompleted() { return false; } + + @Override + public Entity.EntityType entityType() { + return Entity.EntityType.TABLE; + } }); } }; @@ -482,7 +498,10 @@ public class TestIcebergMetadataAuthorizationMethodInterceptor { new IcebergMetadataAuthorizationMethodInterceptor() { @Override protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { throw new RuntimeException("test"); } }; diff --git a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestRegisterTableAuthzHandler.java b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestRegisterTableAuthzHandler.java new file mode 100644 index 0000000000..926c7e38dd --- /dev/null +++ b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/server/web/filter/TestRegisterTableAuthzHandler.java @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.server.web.filter; + +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.doAnswer; +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.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import javax.ws.rs.core.Response; +import org.aopalliance.intercept.MethodInvocation; +import org.apache.gravitino.GravitinoEnv; +import org.apache.gravitino.MetadataObject; +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.iceberg.service.CatalogWrapperForREST; +import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager; +import org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext; +import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider; +import org.apache.gravitino.iceberg.service.rest.IcebergNamespaceOperations; +import org.apache.gravitino.listener.EventBus; +import org.apache.gravitino.listener.api.event.BaseEvent; +import org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent; +import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider; +import org.apache.gravitino.utils.NameIdentifierUtil; +import org.apache.gravitino.utils.PrincipalUtils; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.apache.iceberg.rest.responses.ListNamespacesResponse; +import org.apache.iceberg.rest.responses.ListTablesResponse; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +/** + * Tests that Iceberg REST {@code registerTable} authz requires modify/owner for overwrite and + * load-table rights when adopting another live table's metadata. + */ +class TestRegisterTableAuthzHandler { + + 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 TABLE = "existing_table"; + private static final String META_LOC = "file:/warehouse/other/secret/metadata/v1.metadata.json"; + private static final String TABLE_LOC = "file:/warehouse/other/secret"; + private static final String PROCEEDED = "PROCEEDED"; + + private CatalogWrapperForREST catalogWrapper; + private GravitinoAuthorizer authorizer; + private IcebergMetadataAuthorizationMethodInterceptor interceptor; + private MockedStatic<PrincipalUtils> principalUtils; + private MockedStatic<AuthorizationUtils> authorizationUtils; + private MockedStatic<GravitinoAuthorizerProvider> authorizerProvider; + + @BeforeEach + void setUp() { + catalogWrapper = mock(CatalogWrapperForREST.class); + when(catalogWrapper.isRESTCatalog()).thenReturn(false); + stubEmptyCatalog(); + when(catalogWrapper.loadTableMetadataFromLocation(any())).thenReturn(tableMetadata()); + + IcebergCatalogWrapperManager wrapperManager = mock(IcebergCatalogWrapperManager.class); + when(wrapperManager.getCatalogWrapper(CATALOG)).thenReturn(catalogWrapper); + + IcebergConfigProvider configProvider = mock(IcebergConfigProvider.class); + when(configProvider.getMetalakeName()).thenReturn(METALAKE); + when(configProvider.getDefaultCatalogName()).thenReturn(CATALOG); + IcebergRESTServerContext.create(configProvider, true, false, false, wrapperManager); + + 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 IcebergMetadataAuthorizationMethodInterceptor(); + } + + @AfterEach + void tearDown() { + authorizerProvider.close(); + authorizationUtils.close(); + principalUtils.close(); + } + + @Test + void testCreateTableAloneCannotOverwrite() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + + MethodInvocation overwrite = registerInvocation(true, TABLE, META_LOC); + Object result = interceptor.invoke(overwrite); + assertForbidden(result); + verify(overwrite, never()).proceed(); + } + + @Test + void testModifyTableAllowsOverwrite() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.MODIFY_TABLE); + + assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(true, TABLE, META_LOC))); + } + + @Test + void testTableOwnerAllowsOverwriteWithoutModifyPrivilege() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA); + ownerOf(MetadataObject.Type.TABLE); + + assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(true, TABLE, META_LOC))); + } + + @Test + void testCreateTableStillAllowsPlainRegisterOfOrphanedMetadata() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + + assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(false, TABLE, META_LOC))); + } + + @Test + void testCreateTableAloneCannotAdoptTrackedSource() throws Throwable { + TableIdentifier source = TableIdentifier.of("other", "secret"); + stubCatalogWithSource(source); + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + + MethodInvocation invocation = registerInvocation(false, "copy", META_LOC); + Object result = interceptor.invoke(invocation); + assertForbidden(result); + verify(invocation, never()).proceed(); + } + + @Test + void testSelectTableAllowsAdoptTrackedSource() throws Throwable { + TableIdentifier source = TableIdentifier.of("other", "secret"); + stubCatalogWithSource(source); + allow( + Privilege.Name.USE_CATALOG, + Privilege.Name.USE_SCHEMA, + Privilege.Name.CREATE_TABLE, + Privilege.Name.SELECT_TABLE); + + assertEquals(PROCEEDED, interceptor.invoke(registerInvocation(false, "copy", META_LOC))); + } + + @Test + void testOverwriteDenialChecksTableBeforeScanningSources() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + + MethodInvocation overwrite = registerInvocation(true, TABLE, META_LOC); + assertForbidden(interceptor.invoke(overwrite)); + verify(catalogWrapper, never()).listNamespace(any()); + verify(catalogWrapper, never()).loadTableMetadataFromLocation(any()); + } + + @Test + void testCreateDenialChecksDestinationBeforeReadingMetadata() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA); + + MethodInvocation create = registerInvocation(false, TABLE, META_LOC); + assertForbidden(interceptor.invoke(create)); + verify(catalogWrapper, never()).loadTableMetadataFromLocation(any()); + verify(catalogWrapper, never()).listNamespace(any()); + } + + @Test + void testOverwriteDenialDispatchesAuditAgainstTable() throws Throwable { + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + EventBus eventBus = mock(EventBus.class); + try (MockedStatic<GravitinoEnv> env = mockGravitinoEnv(eventBus)) { + assertForbidden(interceptor.invoke(registerInvocation(true, TABLE, META_LOC))); + + ArgumentCaptor<BaseEvent> event = ArgumentCaptor.forClass(BaseEvent.class); + verify(eventBus).dispatchEvent(event.capture()); + assertEquals( + NameIdentifierUtil.ofTable(METALAKE, CATALOG, SCHEMA, TABLE), + assertInstanceOf(AuthorizationDenialFailureEvent.class, event.getValue()).identifier()); + } + } + + @Test + void testSourceDenialDispatchesAuditAgainstSchema() throws Throwable { + TableIdentifier source = TableIdentifier.of("other", "secret"); + stubCatalogWithSource(source); + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + EventBus eventBus = mock(EventBus.class); + try (MockedStatic<GravitinoEnv> env = mockGravitinoEnv(eventBus)) { + assertForbidden(interceptor.invoke(registerInvocation(false, "copy", META_LOC))); + + ArgumentCaptor<BaseEvent> event = ArgumentCaptor.forClass(BaseEvent.class); + verify(eventBus).dispatchEvent(event.capture()); + assertEquals( + NameIdentifierUtil.ofSchema(METALAKE, CATALOG, SCHEMA), + assertInstanceOf(AuthorizationDenialFailureEvent.class, event.getValue()).identifier()); + } + } + + @Test + void testUnreadableSourceMetadataBlocksRegister() throws Throwable { + TableIdentifier source = TableIdentifier.of("other", "secret"); + stubCatalogWithSource(source); + when(catalogWrapper.getTableMetadataLocation(source)) + .thenReturn(Optional.of("file:/warehouse/other/secret/metadata/v2.metadata.json")); + when(catalogWrapper.loadTableMetadata(source)).thenThrow(new RuntimeException("corrupt")); + allow(Privilege.Name.USE_CATALOG, Privilege.Name.USE_SCHEMA, Privilege.Name.CREATE_TABLE); + + MethodInvocation invocation = registerInvocation(false, "copy", META_LOC); + interceptor.invoke(invocation); + verify(invocation, never()).proceed(); + } + + private MethodInvocation registerInvocation( + boolean overwrite, String tableName, String metadataLocation) throws Throwable { + Method method = + IcebergNamespaceOperations.class.getMethod( + "registerTable", String.class, String.class, RegisterTableRequest.class, String.class); + RegisterTableRequest request = + ImmutableRegisterTableRequest.builder() + .name(tableName) + .metadataLocation(metadataLocation) + .overwrite(overwrite) + .build(); + MethodInvocation invocation = mock(MethodInvocation.class); + when(invocation.getMethod()).thenReturn(method); + when(invocation.getArguments()).thenReturn(new Object[] {CATALOG + "/", SCHEMA, request, null}); + when(invocation.proceed()).thenReturn(PROCEEDED); + return invocation; + } + + private void stubEmptyCatalog() { + ListNamespacesResponse emptyNs = mock(ListNamespacesResponse.class); + when(emptyNs.namespaces()).thenReturn(Collections.emptyList()); + ListTablesResponse emptyTables = mock(ListTablesResponse.class); + when(emptyTables.identifiers()).thenReturn(Collections.emptyList()); + when(catalogWrapper.listNamespace(any())).thenReturn(emptyNs); + when(catalogWrapper.listTable(any())).thenReturn(emptyTables); + } + + private void stubCatalogWithSource(TableIdentifier source) { + Namespace ns = source.namespace(); + ListNamespacesResponse rootNs = mock(ListNamespacesResponse.class); + when(rootNs.namespaces()).thenReturn(List.of(ns)); + ListNamespacesResponse childNs = mock(ListNamespacesResponse.class); + when(childNs.namespaces()).thenReturn(Collections.emptyList()); + ListTablesResponse childTables = mock(ListTablesResponse.class); + when(childTables.identifiers()).thenReturn(List.of(source)); + + when(catalogWrapper.listNamespace(Namespace.empty())).thenReturn(rootNs); + when(catalogWrapper.listNamespace(ns)).thenReturn(childNs); + // Like Iceberg's HiveCatalog, the root namespace cannot list tables. + when(catalogWrapper.listTable(Namespace.empty())) + .thenThrow(new IllegalArgumentException("Missing database in namespace: ")); + when(catalogWrapper.listTable(ns)).thenReturn(childTables); + when(catalogWrapper.getTableMetadataLocation(source)).thenReturn(Optional.of(META_LOC)); + when(catalogWrapper.loadTableMetadata(source)).thenReturn(tableMetadata()); + } + + private static TableMetadata tableMetadata() { + Schema schema = new Schema(Types.NestedField.optional(1, "id", Types.LongType.get())); + return TableMetadata.newTableMetadata( + schema, PartitionSpec.unpartitioned(), TABLE_LOC, Collections.emptyMap()); + } + + 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(invocation -> allowed.contains(invocation.getArgument(3))) + .when(authorizer) + .authorize(any(), any(), any(), any(), any()); + } + + private static MockedStatic<GravitinoEnv> mockGravitinoEnv(EventBus eventBus) { + MockedStatic<GravitinoEnv> envStatic = mockStatic(GravitinoEnv.class); + GravitinoEnv env = mock(GravitinoEnv.class); + envStatic.when(GravitinoEnv::getInstance).thenReturn(env); + when(env.eventBus()).thenReturn(eventBus); + return envStatic; + } + + private static void assertForbidden(Object result) { + Response response = assertInstanceOf(Response.class, result); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertInstanceOf(ErrorResponse.class, response.getEntity()); + } +} 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 index 88b3d645ec..16f6def5c9 100644 --- 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 @@ -162,13 +162,29 @@ public class LanceMetadataAuthorizationMethodInterceptor */ @Override protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { String overwriteExpression = isTableOperation(method) ? LanceAuthorizationExpressions.MODIFY_TABLE_AUTHORIZATION_EXPRESSION : LanceAuthorizationExpressions.MODIFY_NAMESPACE_AUTHORIZATION_EXPRESSION; return createMode(parameters, args) - .map(mode -> new OverwriteAuthzHandler(mode, overwriteExpression)); + .map( + mode -> + new OverwriteAuthzHandler( + mode, overwriteExpression, deepestEntityType(nameIdentifierMap))); + } + + private static Entity.EntityType deepestEntityType( + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { + if (nameIdentifierMap.containsKey(Entity.EntityType.TABLE)) { + return Entity.EntityType.TABLE; + } + return nameIdentifierMap.containsKey(Entity.EntityType.SCHEMA) + ? Entity.EntityType.SCHEMA + : Entity.EntityType.CATALOG; } @Override @@ -217,8 +233,10 @@ public class LanceMetadataAuthorizationMethodInterceptor private final boolean overwrite; private final String authorizationExpression; + private final Entity.EntityType entityType; - private OverwriteAuthzHandler(String mode, String authorizationExpression) { + private OverwriteAuthzHandler( + String mode, String authorizationExpression, Entity.EntityType entityType) { // Read the mode through the same normalization the create operation applies, so a token the // operation will act on as an overwrite cannot be authorized as a plain create. Comparing the // raw string here would leave a gap: " overwrite " reaches the operation as OVERWRITE but @@ -226,6 +244,7 @@ public class LanceMetadataAuthorizationMethodInterceptor // by somebody else. this.overwrite = OVERWRITE_MODE.equals(CommonUtil.normalizeToken(mode)); this.authorizationExpression = authorizationExpression; + this.entityType = entityType; } @Override @@ -234,7 +253,6 @@ public class LanceMetadataAuthorizationMethodInterceptor return; } - Entity.EntityType entityType = deepestEntityType(nameIdentifierMap); boolean authorized = new AuthorizationExpressionEvaluator(authorizationExpression) .evaluate( @@ -256,14 +274,9 @@ public class LanceMetadataAuthorizationMethodInterceptor return overwrite; } - private static Entity.EntityType deepestEntityType( - Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { - if (nameIdentifierMap.containsKey(Entity.EntityType.TABLE)) { - return Entity.EntityType.TABLE; - } - return nameIdentifierMap.containsKey(Entity.EntityType.SCHEMA) - ? Entity.EntityType.SCHEMA - : Entity.EntityType.CATALOG; + @Override + public Entity.EntityType entityType() { + return entityType; } } diff --git a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java index 05a6b32bf4..cb7b4e27e4 100644 --- a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java +++ b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationExpression.java @@ -53,14 +53,16 @@ public @interface AuthorizationExpression { String errorMessage() default ""; /** - * The secondary expression to evaluate for authorization, which represents multiple privileges. + * The secondary expression to evaluate for authorization when {@link + * #secondaryExpressionCondition()} matches. Typical uses are stricter load checks (for example + * MODIFY_TABLE privileges) and catalog connection-test paths that carry proposed changes. * * @return the secondary expression to evaluate for authorization. */ String secondaryExpression() default ""; /** - * The condition under which the secondary expression is evaluated. + * The condition under which {@link #secondaryExpression()} replaces {@link #expression()}. * * @return the condition for evaluating the secondary expression. */ diff --git a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java index 528d62b528..a3b5efb86d 100644 --- a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java +++ b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java @@ -119,6 +119,14 @@ public abstract class BaseMetadataAuthorizationMethodInterceptor { * standard expression-based authorization */ boolean authorizationCompleted(); + + /** + * Entity type used to attribute denials and audit events for this handler. Called before {@link + * #process}. + * + * @return the access entity type this handler authorizes against + */ + Entity.EntityType entityType(); } /** Invokes the protocol operation after authorization succeeds. */ @@ -167,11 +175,16 @@ public abstract class BaseMetadataAuthorizationMethodInterceptor { * @param method REST method being invoked * @param parameters Method parameters * @param args Method arguments + * @param nameIdentifierMap identifiers resolved for this request; handlers may add to it before + * {@link AuthorizationHandler#process} for denial attribution * @return Optional handler for custom authorization processing, or empty if standard * authorization is sufficient */ protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { return Optional.empty(); } @@ -294,11 +307,11 @@ public abstract class BaseMetadataAuthorizationMethodInterceptor { } } - if (nameIdentifierMap.get(target.entityType()) != null) { - deniedObject = nameIdentifierMap.get(target.entityType()); - } Optional<AuthorizationHandler> handler = - createAuthorizationHandler(method, parameters, args); + createAuthorizationHandler(method, parameters, args, nameIdentifierMap); + Entity.EntityType entityType = + handler.isPresent() ? handler.get().entityType() : target.entityType(); + deniedObject = nameIdentifierMap.get(entityType); if (!skipStandardCheck && handler.isPresent()) { AuthorizationHandler authzHandler = handler.get(); diff --git a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java index 54d24d4890..73ad0be954 100644 --- a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java +++ b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java @@ -246,6 +246,11 @@ public class TestBaseMetadataAuthorizationMethodInterceptor { public boolean authorizationCompleted() { return true; } + + @Override + public Entity.EntityType entityType() { + return Entity.EntityType.SCHEMA; + } }); TestInvocation invocation = invocation("alwaysDenied", "authorized by handler"); @@ -305,6 +310,11 @@ public class TestBaseMetadataAuthorizationMethodInterceptor { public boolean authorizationCompleted() { return false; } + + @Override + public Entity.EntityType entityType() { + return Entity.EntityType.SCHEMA; + } }); TestInvocation invocation = invocation("canAccessMetadata", "authorized"); @@ -355,6 +365,174 @@ public class TestBaseMetadataAuthorizationMethodInterceptor { verify(invocation).proceed(); } +<<<<<<< HEAD +======= + @Test + public void testHandlerDenialIsRecordedAgainstHandlerTarget() throws Throwable { + NameIdentifier table = NameIdentifierUtil.ofTable("metalake", "catalog", "schema", "table"); + EventBus eventBus = mock(EventBus.class); + try (MockedStatic<GravitinoEnv> envStatic = mockGravitinoEnv(eventBus)) { + TestInterceptor interceptor = + new TestInterceptor(Entity.EntityType.SCHEMA) { + @Override + protected AuthorizationTarget resolveAuthorizationTarget( + Method method, + AuthorizationExpression annotation, + Parameter[] parameters, + Object[] args) { + AuthorizationTarget target = + super.resolveAuthorizationTarget(method, annotation, parameters, args); + // Bound before process so denial attribution can use handler.entityType(). + target.nameIdentifiers().put(Entity.EntityType.TABLE, table); + return target; + } + }; + interceptor.denialEventSource = Optional.of(EventSource.GRAVITINO_ICEBERG_REST_SERVER); + interceptor.handler = + Optional.of( + new BaseMetadataAuthorizationMethodInterceptor.AuthorizationHandler() { + @Override + public void process(Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { + throw new ForbiddenException("denied by handler"); + } + + @Override + public boolean authorizationCompleted() { + return true; + } + + @Override + public Entity.EntityType entityType() { + return Entity.EntityType.TABLE; + } + }); + + assertInstanceOf( + ForbiddenException.class, + interceptor.invoke(invocation("canAccessMetadata", "authorized"))); + + ArgumentCaptor<BaseEvent> event = ArgumentCaptor.forClass(BaseEvent.class); + verify(eventBus).dispatchEvent(event.capture()); + assertEquals( + table, + assertInstanceOf(AuthorizationDenialFailureEvent.class, event.getValue()).identifier()); + } + } + + /** Entry authorization, parent checks and worker filtering share one user lookup. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testReadListReusesEntryContext(boolean hasDeny) throws Throwable { + AtomicInteger userLoads = new AtomicInteger(); + Set<AuthorizationRequestContext> contexts = ConcurrentHashMap.newKeySet(); + AtomicBoolean perObjectLoopRan = new AtomicBoolean(); + AtomicReference<AuthorizationRequestContext> entryContext = new AtomicReference<>(); + Function<String, Optional<UserUpdatedAt>> loadUser = + key -> { + userLoads.incrementAndGet(); + return Optional.of(new UserUpdatedAt(1L, 1L)); + }; + try (MockedStatic<GravitinoEnv> envStatic = mockStatic(GravitinoEnv.class)) { + GravitinoEnv env = mock(GravitinoEnv.class); + Config config = mock(Config.class); + envStatic.when(GravitinoEnv::getInstance).thenReturn(env); + when(env.config()).thenReturn(config); + when(config.get(Configs.ENABLE_AUTHORIZATION)).thenReturn(true); + when(config.get(Configs.GRAVITINO_AUTHORIZATION_THREAD_POOL_SIZE)).thenReturn(2); + principalUtils.when(() -> PrincipalUtils.doAs(any(), any())).thenCallRealMethod(); + authorizationUtils + .when(() -> AuthorizationUtils.checkCurrentUser(any(), any(), any())) + .thenAnswer( + invocation -> { + AuthorizationRequestContext context = invocation.getArgument(2); + entryContext.set(context); + context.computeUserInfoIfAbsent("metalake::tester", loadUser); + return null; + }); + when(authorizer.authorize(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + AuthorizationRequestContext context = invocation.getArgument(4); + contexts.add(context); + context.computeUserInfoIfAbsent("metalake::tester", loadUser); + return true; + }); + when(authorizer.hasDenyPolicy(any(), any(), any(), any())) + .thenAnswer( + invocation -> { + contexts.add(invocation.getArgument(3)); + return hasDeny; + }); + when(authorizer.deny(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + MetadataObject object = invocation.getArgument(2); + contexts.add(invocation.getArgument(4)); + if (object.type() == MetadataObject.Type.TABLE) { + perObjectLoopRan.set(true); + } + return hasDeny && object.name().equals("hidden"); + }); + NameIdentifier visible = NameIdentifier.of("metalake", "catalog", "schema", "visible"); + NameIdentifier hidden = NameIdentifier.of("metalake", "catalog", "schema", "hidden"); + TestInvocation invocation = invocation("listTables", null); + when(invocation.proceed()) + .thenAnswer( + unused -> + MetadataAuthzHelper.filterByExpression( + "metalake", + AuthorizationExpressionConstants.FILTER_TABLE_AUTHORIZATION_EXPRESSION, + Entity.EntityType.TABLE, + new NameIdentifier[] {visible, hidden})); + + Object result = new TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation); + + assertArrayEquals( + hasDeny ? new NameIdentifier[] {visible} : new NameIdentifier[] {visible, hidden}, + (NameIdentifier[]) result); + assertEquals(Set.of(entryContext.get()), contexts); + assertEquals(1, userLoads.get()); + assertEquals(hasDeny, perObjectLoopRan.get()); + assertNotSame(entryContext.get(), AuthorizationRequestScope.getOrCreate("metalake")); + } + } + + /** An endpoint failure must not retain a request's permission cache on a reused thread. */ + @Test + public void testReadContextIsClearedAfterOperationFailure() throws Throwable { + when(authorizer.authorize(any(), any(), any(), any(), any())).thenReturn(true); + AtomicReference<AuthorizationRequestContext> context = new AtomicReference<>(); + TestInvocation invocation = invocation("listTables", null); + IllegalStateException failure = new IllegalStateException("list failed"); + when(invocation.proceed()) + .thenAnswer( + unused -> { + context.set(AuthorizationRequestScope.getOrCreate("metalake")); + throw failure; + }); + assertSame(failure, new TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation)); + assertNotSame(context.get(), AuthorizationRequestScope.getOrCreate("metalake")); + } + + /** Write operations must not expose pre-mutation authorization decisions to later filtering. */ + @Test + public void testWriteDoesNotReuseEntryContext() throws Throwable { + AtomicReference<AuthorizationRequestContext> entryContext = new AtomicReference<>(); + when(authorizer.authorize(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + entryContext.set(invocation.getArgument(4)); + return true; + }); + TestInvocation invocation = invocation("writeTables", null); + when(invocation.proceed()) + .thenAnswer(unused -> AuthorizationRequestScope.getOrCreate("metalake")); + Object result = new TestInterceptor(Entity.EntityType.SCHEMA).invoke(invocation); + assertInstanceOf(AuthorizationRequestContext.class, result); + assertNotSame(entryContext.get(), result); + } + +>>>>>>> f1a13e7ce ([#14242] fix(iceberg-rest): Authorize registerTable overwrite and foreign metadata sources (#14482)) private static TestInvocation invocation(String methodName, Object result) throws Throwable { Method method = TestOperations.class.getDeclaredMethod(methodName); TestInvocation invocation = mock(TestInvocation.class); @@ -413,10 +591,13 @@ public class TestBaseMetadataAuthorizationMethodInterceptor { @Override protected Optional<AuthorizationHandler> createAuthorizationHandler( - Method method, Parameter[] parameters, Object[] args) { + Method method, + Parameter[] parameters, + Object[] args, + Map<Entity.EntityType, NameIdentifier> nameIdentifierMap) { return handler.isPresent() ? handler - : super.createAuthorizationHandler(method, parameters, args); + : super.createAuthorizationHandler(method, parameters, args, nameIdentifierMap); } @Override
