Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
- The configuration option `polaris.event-listener.type` is deprecated and will be removed later. Please use `polaris.event-listener.types` instead.

### Fixes
- Fixed native catalog credential vending paths (`loadCredentials` and `loadTable` with delegation) to re-validate locations against the *current* catalog `allowedLocations`. Previously these paths trusted persisted table entity locations, allowing stale credentials after an admin tightened allowed locations on a native catalog. (The federated path had a partial check.)

## [1.4.0]

Expand Down Expand Up @@ -153,7 +154,6 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
- `PolarisConfigurationStore` has been deprecated for removal.

### Fixes

- Fixed error propagation in drop operations (`dropTable`, `dropView`, `dropNamespace`). Server errors now return appropriate HTTP status codes based on persistence result instead of always returning NotFound
- Enable non-AWS STS role ARNs
- Helm chart: fixed a bug that prevented CORS settings to be properly applied. A new setting `cors.enabled` has been introduced in the chart as part of the fix.
Expand Down Expand Up @@ -231,7 +231,6 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
endpoints at `/q/metrics` and `/q/health` instead.

### Fixes

- Fixed incorrect Azure expires at field for the credentials refresh response, leading to client failure via #2633

## [1.1.0-incubating]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1021,10 +1021,10 @@ private LoadTableResponse.Builder buildLoadTableResponseWithDelegationCredential

Set<String> tableLocations = StorageUtil.getLocationsUsedByTable(tableMetadata);

// For federated catalogs, validate that table locations are within allowed locations
if (isFederated) {
validateRemoteTableLocations(tableIdentifier, tableLocations, resolvedStoragePath);
}
// Validate that the table's locations are still within the catalog's current
// allowedLocations before vending credentials. This protects against cases where
// allowedLocations were tightened after the table was created.
validateTableLocations(tableIdentifier, tableLocations, resolvedStoragePath);

StorageAccessConfig storageAccessConfig =
storageAccessConfigProvider()
Expand Down Expand Up @@ -1058,29 +1058,31 @@ private LoadTableResponse.Builder buildLoadTableResponseWithDelegationCredential
return responseBuilder;
}

private void validateRemoteTableLocations(
private void validateTableLocations(
TableIdentifier tableIdentifier,
Set<String> tableLocations,
PolarisResolvedPathWrapper resolvedStoragePath) {

try {
// Delegate to common validation logic
// Delegate to common validation logic. This is called for both native and federated
// catalogs before vending credentials to ensure locations are still within the
// current catalog's allowedLocations (defense against policy changes after table creation).
CatalogUtils.validateLocationsForTableLike(
realmConfig(), tableIdentifier, tableLocations, resolvedStoragePath);

LOGGER
.atInfo()
.addKeyValue("tableIdentifier", tableIdentifier)
.addKeyValue("tableLocations", tableLocations)
.log("Validated federated table locations");
.log("Validated table locations for credential vending");
} catch (ForbiddenException e) {
LOGGER
.atError()
.addKeyValue("tableIdentifier", tableIdentifier)
.addKeyValue("tableLocations", tableLocations)
.log("Federated table locations validation failed");
.log("Table locations validation failed for credential vending");
throw new ForbiddenException(
"Table '%s' in remote catalog has locations outside catalog's allowed locations: %s",
"Table '%s' has locations outside the catalog's current allowed locations: %s",
tableIdentifier, e.getMessage());
}
}
Expand Down Expand Up @@ -1567,6 +1569,10 @@ private StorageAccessConfig vendCredentials(
return null;
}

// Re-validate before vending in case this is called from other paths in the future.
// Primary validation for loadCredentials and delegation happens at call sites.
validateTableLocations(tableIdentifier, tableLocations, resolvedStoragePath);

return storageAccessConfigProvider()
.getStorageAccessConfig(
tableIdentifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.apache.iceberg.MetadataUpdate;
import org.apache.iceberg.catalog.Namespace;
Expand All @@ -51,6 +52,7 @@
import org.apache.polaris.core.admin.model.CreateCatalogRequest;
import org.apache.polaris.core.admin.model.FileStorageConfigInfo;
import org.apache.polaris.core.admin.model.StorageConfigInfo;
import org.apache.polaris.core.admin.model.UpdateCatalogRequest;
import org.apache.polaris.service.TestServices;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -549,4 +551,88 @@ private void createNamespace(TestServices services, String location) {
assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
}
}

private void updateCatalogAllowedLocations(
TestServices services, List<String> newAllowedLocations) {
// Fetch current catalog to get entity version for update.
Catalog fetched;
try (Response getResp =
services
.catalogsApi()
.getCatalog(catalog, services.realmContext(), services.securityContext())) {
assertThat(getResp.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
fetched = getResp.readEntity(Catalog.class);
}

StorageConfigInfo newConfig =
FileStorageConfigInfo.builder()
.setStorageType(StorageConfigInfo.StorageTypeEnum.FILE)
.setAllowedLocations(newAllowedLocations)
.build();

UpdateCatalogRequest updateRequest =
UpdateCatalogRequest.builder()
.setCurrentEntityVersion(fetched.getEntityVersion())
.setStorageConfigInfo(newConfig)
.build();

try (Response updateResp =
services
.catalogsApi()
.updateCatalog(
catalog, updateRequest, services.realmContext(), services.securityContext())) {
assertThat(updateResp.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
}
}

@Test
void testNativeCredentialVendingRejectsStaleLocationsAfterAllowedLocationsShrink(
@TempDir Path tmpDir) {
var services = getTestServices();

var baseDir = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString();
var allowedWhenCreated = baseDir + "/original-data";
var disallowedAfterShrink = baseDir + "/other-data";

// Catalog initially allows the original location
createCatalog(services, Map.of(), baseDir, List.of(allowedWhenCreated));
createNamespace(services, allowedWhenCreated + "/" + namespace);

String tableName = getTableName();
TableIdentifier tableId = TableIdentifier.of(Namespace.of(namespace), tableName);

var createReq =
CreateTableRequest.builder()
.withName(tableName)
.withSchema(SCHEMA)
.withLocation(allowedWhenCreated + "/" + namespace + "/" + tableName)
.build();

// Create succeeds under current allowed
try (Response createResp =
services
.restApi()
.createTable(
catalog,
namespace,
createReq,
"vended-credentials",
IDEMPOTENCY_KEY,
services.realmContext(),
services.securityContext())) {
assertThat(createResp.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
}

// Now shrink: update catalog to only allow a completely different location
updateCatalogAllowedLocations(services, List.of(disallowedAfterShrink));

// Credential vending for the existing table (which has stale location in its entity)
// must now be rejected.
IcebergCatalogHandler handler =
services.catalogAdapter().newHandler(services.securityContext(), catalog);

assertThatThrownBy(() -> handler.loadCredentials(tableId, Optional.empty()))
.isInstanceOf(ForbiddenException.class)
.hasMessageContaining("outside the catalog's current allowed locations");
}
}
Loading