Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,11 @@ protected FeatureConfiguration(
"How many times to retry refreshing metadata when the previous error was retryable")
.defaultValue(2)
.buildFeatureConfiguration();

public static final FeatureConfiguration<Boolean> ENABLE_GENERIC_TABLES =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Question: how do we configure this? Do we just put the key in the file application.properties like this?

ENABLE_GENERIC_TABLES=true

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The syntax is a little different, but basically yeah. It will be similar to existing configs, so:

polaris.features.defaults."ENABLE_GENERIC_TABLES"=false

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we will need a doc for this config. Not a blocker though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config descriptions are meant to be self-describing, but I really want us to auto-generate some docs based on these. I think we've talked about this for a while but not sure if anyone is working on it yet

PolarisConfiguration.<Boolean>builder()
.key("ENABLE_GENERIC_TABLES")
.description("If true, the generic-tables endpoints are enabled")
.defaultValue(false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we want it to be enabled by default. Do we?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to keep this as false until the whole feature finish development. Once all server change is done, @eric-maynard will have a PR to switch it to true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the REST API is not wired up, so I think it's okay to keep it flagged off for the time being. Once the series of PR is finished and the feature is "complete" we should enable by default

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that we will enable by default when all the relevant code parts are ready. The same shall apply for policy endpoints

.buildFeatureConfiguration();
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
public class GenericTableEntity extends TableLikeEntity {

public static final String FORMAT_KEY = "format";
public static final String DOC_KEY = "doc";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker: either doc or description is fine to me. I like description a bit more, it is more commonly used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec calls this field doc, otherwise I agree

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "doc" is used as the API field, to keep things consistent, how about let's just keep it as doc. I was using doc since it was some term used in iceberg https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml#L2079. i was originally intended to use name "comment", it was a more commonly used name in other table catalog like unity and gravitino


public GenericTableEntity(PolarisBaseEntity sourceEntity) {
super(sourceEntity);
Expand All @@ -52,6 +53,11 @@ public String getFormat() {
return getInternalPropertiesAsMap().get(GenericTableEntity.FORMAT_KEY);
}

@JsonIgnore
public String getDoc() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non-blocking question] Just to confirm my understanding, we reserve "properties" for generic table's location, connection info, etc, so we put other information like format and doc in the internalProperties? Do we have any convention like things in the internal properties should not expose to users?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, internalProperties and properties work that way right now. This is specifically relevant when we need to build the properties map in the response

return getInternalPropertiesAsMap().get(GenericTableEntity.DOC_KEY);
}

public static class Builder
extends PolarisEntity.BaseBuilder<GenericTableEntity, GenericTableEntity.Builder> {
public Builder(TableIdentifier tableIdentifier, String format) {
Expand All @@ -68,6 +74,11 @@ public GenericTableEntity.Builder setFormat(String format) {
return this;
}

public GenericTableEntity.Builder setDoc(String doc) {
internalProperties.put(GenericTableEntity.DOC_KEY, doc);
return this;
}

public GenericTableEntity.Builder setTableIdentifier(TableIdentifier identifier) {
Namespace namespace = identifier.namespace();
setParentNamespace(namespace);
Expand Down
2 changes: 2 additions & 0 deletions quarkus/service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dependencies {
implementation(project(":polaris-core"))
implementation(project(":polaris-api-management-service"))
implementation(project(":polaris-api-iceberg-service"))
implementation(project(":polaris-api-catalog-service"))

implementation(project(":polaris-service-common"))
implementation(project(":polaris-quarkus-defaults"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
import org.apache.polaris.core.persistence.transactional.TransactionalPersistence;
import org.apache.polaris.service.admin.PolarisAdminService;
import org.apache.polaris.service.catalog.PolarisPassthroughResolutionView;
import org.apache.polaris.service.catalog.generic.GenericTableCatalog;
import org.apache.polaris.service.catalog.iceberg.IcebergCatalog;
import org.apache.polaris.service.catalog.io.FileIOFactory;
import org.apache.polaris.service.config.DefaultConfigurationStore;
Expand Down Expand Up @@ -131,6 +132,10 @@ public Map<String, String> getConfigOverrides() {
// One table directly under ns1
protected static final TableIdentifier TABLE_NS1_1 = TableIdentifier.of(NS1, "layer1_table");

// A generic table directly under ns1
protected static final TableIdentifier TABLE_NS1_1_GENERIC =
TableIdentifier.of(NS1, "layer1_table_generic");

// Two tables under ns1a
protected static final TableIdentifier TABLE_NS1A_1 = TableIdentifier.of(NS1A, "table1");
protected static final TableIdentifier TABLE_NS1A_2 = TableIdentifier.of(NS1A, "table2");
Expand Down Expand Up @@ -175,6 +180,7 @@ public Map<String, String> getConfigOverrides() {
@Inject protected FileIOFactory fileIOFactory;

protected IcebergCatalog baseCatalog;
protected GenericTableCatalog genericTableCatalog;
protected PolarisAdminService adminService;
protected PolarisEntityManager entityManager;
protected PolarisMetaStoreManager metaStoreManager;
Expand All @@ -201,7 +207,9 @@ public void before(TestInfo testInfo) {

Map<String, Object> configMap =
Map.of(
"ALLOW_SPECIFYING_FILE_IO_IMPL", true, "ALLOW_EXTERNAL_METADATA_FILE_LOCATION", true);
"ALLOW_SPECIFYING_FILE_IO_IMPL", true,
"ALLOW_EXTERNAL_METADATA_FILE_LOCATION", true,
"ENABLE_GENERIC_TABLES", true);
polarisContext =
new PolarisCallContext(
managerFactory.getOrCreateSessionSupplier(realmContext).get(),
Expand Down Expand Up @@ -302,6 +310,8 @@ public void before(TestInfo testInfo) {
baseCatalog.buildTable(TABLE_NS1B_1, SCHEMA).create();
baseCatalog.buildTable(TABLE_NS2_1, SCHEMA).create();

genericTableCatalog.createGenericTable(TABLE_NS1_1_GENERIC, "format", "doc", Map.of());

baseCatalog
.buildView(VIEW_NS1_1)
.withSchema(SCHEMA)
Expand Down Expand Up @@ -442,6 +452,8 @@ private void initBaseCatalog() {
CATALOG_NAME,
ImmutableMap.of(
CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO"));
this.genericTableCatalog =
new GenericTableCatalog(metaStoreManager, callContext, passthroughView);
}

@Alternative
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/*
* 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.polaris.service.quarkus.catalog;

import io.quarkus.test.junit.QuarkusTest;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal;
import org.apache.polaris.core.entity.PolarisPrivilege;
import org.apache.polaris.service.catalog.generic.GenericTableCatalogHandlerWrapper;
import org.apache.polaris.service.quarkus.admin.PolarisAuthzTestBase;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

@QuarkusTest
public class GenericTableCatalogHandlerWrapperAuthzTest extends PolarisAuthzTestBase {

private GenericTableCatalogHandlerWrapper newWrapper() {
return newWrapper(Set.of());
}

private GenericTableCatalogHandlerWrapper newWrapper(Set<String> activatedPrincipalRoles) {
return newWrapper(activatedPrincipalRoles, CATALOG_NAME);
}

private GenericTableCatalogHandlerWrapper newWrapper(
Set<String> activatedPrincipalRoles, String catalogName) {
final AuthenticatedPolarisPrincipal authenticatedPrincipal =
new AuthenticatedPolarisPrincipal(principalEntity, activatedPrincipalRoles);
return new GenericTableCatalogHandlerWrapper(
callContext,
entityManager,
metaStoreManager,
securityContext(authenticatedPrincipal, activatedPrincipalRoles),
catalogName,
polarisAuthorizer);
}

/**
* Tests each "sufficient" privilege individually using CATALOG_ROLE1 by granting at the
* CATALOG_NAME level, revoking after each test, and also ensuring that the request fails after
* revocation.
*
* @param sufficientPrivileges List of privileges that should be sufficient each in isolation for
* {@code action} to succeed.
* @param action The operation being tested; could also be multiple operations that should all
* succeed with the sufficient privilege
* @param cleanupAction If non-null, additional action to run to "undo" a previous success action
* in case the action has side effects. Called before revoking the sufficient privilege;
* either the cleanup privileges must be latent, or the cleanup action could be run with
* PRINCIPAL_ROLE2 while runnint {@code action} with PRINCIPAL_ROLE1.
*/
private void doTestSufficientPrivileges(
List<PolarisPrivilege> sufficientPrivileges, Runnable action, Runnable cleanupAction) {
doTestSufficientPrivilegeSets(
sufficientPrivileges.stream().map(priv -> Set.of(priv)).toList(),
action,
cleanupAction,
PRINCIPAL_NAME);
}

/**
* @param sufficientPrivileges each set of concurrent privileges expected to be sufficient
* together.
* @param action
* @param cleanupAction
* @param principalName
*/
private void doTestSufficientPrivilegeSets(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These helpers seems identical/similar to ones in IcebergCatalogHandlerWrapperAuthzTest. Probably in a follow-up PR, shall we consider extracting these to testBase or some util classes?

Copy link
Contributor Author

@eric-maynard eric-maynard Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree. Actually, I want to refactor the Generic table & Iceberg table catalogs & handlers as well as the tests to see if we can find more common code. But for now, I just kept it totally isolated to make the PR mostly additive.

List<Set<PolarisPrivilege>> sufficientPrivileges,
Runnable action,
Runnable cleanupAction,
String principalName) {
doTestSufficientPrivilegeSets(
sufficientPrivileges, action, cleanupAction, principalName, CATALOG_NAME);
}

/**
* @param sufficientPrivileges each set of concurrent privileges expected to be sufficient
* together.
* @param action
* @param cleanupAction
* @param principalName
* @param catalogName
*/
private void doTestSufficientPrivilegeSets(
List<Set<PolarisPrivilege>> sufficientPrivileges,
Runnable action,
Runnable cleanupAction,
String principalName,
String catalogName) {
doTestSufficientPrivilegeSets(
sufficientPrivileges,
action,
cleanupAction,
principalName,
(privilege) ->
adminService.grantPrivilegeOnCatalogToRole(catalogName, CATALOG_ROLE1, privilege),
(privilege) ->
adminService.revokePrivilegeOnCatalogFromRole(catalogName, CATALOG_ROLE1, privilege));
}

private void doTestInsufficientPrivileges(
List<PolarisPrivilege> insufficientPrivileges, Runnable action) {
doTestInsufficientPrivileges(insufficientPrivileges, PRINCIPAL_NAME, action);
}

/**
* Tests each "insufficient" privilege individually using CATALOG_ROLE1 by granting at the
* CATALOG_NAME level, ensuring the action fails, then revoking after each test case.
*/
private void doTestInsufficientPrivileges(
List<PolarisPrivilege> insufficientPrivileges, String principalName, Runnable action) {
doTestInsufficientPrivileges(
insufficientPrivileges,
principalName,
action,
(privilege) ->
adminService.grantPrivilegeOnCatalogToRole(CATALOG_NAME, CATALOG_ROLE1, privilege),
(privilege) ->
adminService.revokePrivilegeOnCatalogFromRole(CATALOG_NAME, CATALOG_ROLE1, privilege));
}

@Test
public void testListGenericTablesAllSufficientPrivileges() {
doTestSufficientPrivileges(
List.of(
PolarisPrivilege.TABLE_LIST,
PolarisPrivilege.TABLE_READ_PROPERTIES,
PolarisPrivilege.TABLE_WRITE_PROPERTIES,
PolarisPrivilege.TABLE_READ_DATA,
PolarisPrivilege.TABLE_WRITE_DATA,
PolarisPrivilege.TABLE_CREATE,
PolarisPrivilege.TABLE_FULL_METADATA,
PolarisPrivilege.CATALOG_MANAGE_CONTENT),
() -> newWrapper().listGenericTables(NS1A),
null /* cleanupAction */);
}

@Test
public void testListGenericTablesInsufficientPermissions() {
doTestInsufficientPrivileges(
List.of(
PolarisPrivilege.NAMESPACE_FULL_METADATA,
PolarisPrivilege.VIEW_FULL_METADATA,
PolarisPrivilege.TABLE_DROP),
() -> newWrapper().listGenericTables(NS1A));
}

@Test
public void testCreateGenericTableAllSufficientPrivileges() {
Assertions.assertThat(
adminService.grantPrivilegeOnCatalogToRole(
CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_DROP))
.isTrue();
Assertions.assertThat(
adminService.grantPrivilegeOnCatalogToRole(
CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_WRITE_DATA))
.isTrue();

final TableIdentifier newtable = TableIdentifier.of(NS2, "newtable");

// Use PRINCIPAL_ROLE1 for privilege-testing, PRINCIPAL_ROLE2 for cleanup.
doTestSufficientPrivileges(
List.of(
PolarisPrivilege.TABLE_CREATE,
PolarisPrivilege.TABLE_FULL_METADATA,
PolarisPrivilege.CATALOG_MANAGE_CONTENT),
() -> {
newWrapper(Set.of(PRINCIPAL_ROLE1))
.createGenericTable(newtable, "format", "doc", Map.of());
},
() -> {
newWrapper(Set.of(PRINCIPAL_ROLE2)).dropGenericTable(newtable);
});
}

@Test
public void testCreateGenericTableInsufficientPermissions() {
doTestInsufficientPrivileges(
List.of(
PolarisPrivilege.NAMESPACE_FULL_METADATA,
PolarisPrivilege.VIEW_FULL_METADATA,
PolarisPrivilege.TABLE_DROP,
PolarisPrivilege.TABLE_READ_PROPERTIES,
PolarisPrivilege.TABLE_WRITE_PROPERTIES,
PolarisPrivilege.TABLE_READ_DATA,
PolarisPrivilege.TABLE_WRITE_DATA,
PolarisPrivilege.TABLE_LIST),
() -> {
newWrapper(Set.of(PRINCIPAL_ROLE1))
.createGenericTable(TableIdentifier.of(NS2, "newtable"), "format", "doc", Map.of());
});
}

@Test
public void testLoadGenericTableSufficientPrivileges() {
doTestSufficientPrivileges(
List.of(
PolarisPrivilege.TABLE_READ_PROPERTIES,
PolarisPrivilege.TABLE_WRITE_PROPERTIES,
PolarisPrivilege.TABLE_READ_DATA,
PolarisPrivilege.TABLE_WRITE_DATA,
PolarisPrivilege.TABLE_FULL_METADATA,
PolarisPrivilege.CATALOG_MANAGE_CONTENT),
() -> newWrapper().loadGenericTable(TABLE_NS1_1_GENERIC),
null /* cleanupAction */);
}

@Test
public void testLoadTableInsufficientPermissions() {
doTestInsufficientPrivileges(
List.of(
PolarisPrivilege.NAMESPACE_FULL_METADATA,
PolarisPrivilege.VIEW_FULL_METADATA,
PolarisPrivilege.TABLE_CREATE,
PolarisPrivilege.TABLE_LIST,
PolarisPrivilege.TABLE_DROP),
() -> newWrapper().loadGenericTable(TABLE_NS1_1_GENERIC));
}

@Test
public void testDropGenericTableAllSufficientPrivileges() {
Assertions.assertThat(
adminService.grantPrivilegeOnCatalogToRole(
CATALOG_NAME, CATALOG_ROLE2, PolarisPrivilege.TABLE_CREATE))
.isTrue();

doTestSufficientPrivileges(
List.of(
PolarisPrivilege.TABLE_DROP,
PolarisPrivilege.TABLE_FULL_METADATA,
PolarisPrivilege.CATALOG_MANAGE_CONTENT),
() -> {
newWrapper(Set.of(PRINCIPAL_ROLE1)).dropGenericTable(TABLE_NS1_1_GENERIC);
},
() -> {
newWrapper(Set.of(PRINCIPAL_ROLE2))
.createGenericTable(TABLE_NS1_1_GENERIC, "format", "doc", Map.of());
});
}

@Test
public void testDropGenericTableInsufficientPermissions() {
doTestInsufficientPrivileges(
List.of(
PolarisPrivilege.NAMESPACE_FULL_METADATA,
PolarisPrivilege.VIEW_FULL_METADATA,
PolarisPrivilege.TABLE_CREATE,
PolarisPrivilege.TABLE_READ_PROPERTIES,
PolarisPrivilege.TABLE_WRITE_PROPERTIES,
PolarisPrivilege.TABLE_READ_DATA,
PolarisPrivilege.TABLE_WRITE_DATA,
PolarisPrivilege.TABLE_LIST),
() -> {
newWrapper(Set.of(PRINCIPAL_ROLE1)).dropGenericTable(TABLE_NS1_1_GENERIC);
});
}
}
Loading