diff --git a/paimon-api/src/main/java/org/apache/paimon/management/ListPermissionsRequest.java b/paimon-api/src/main/java/org/apache/paimon/management/ListPermissionsRequest.java new file mode 100644 index 000000000000..67558e847a24 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/ListPermissionsRequest.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.paimon.management; + +import org.apache.paimon.annotation.Experimental; + +import javax.annotation.Nullable; + +import static org.apache.paimon.utils.Preconditions.checkArgument; +import static org.apache.paimon.utils.Preconditions.checkNotNull; + +/** Exact resource or scope, principal, and pagination filters for permission assignments. */ +@Experimental +public class ListPermissionsRequest { + + public static final int MAX_PAGE_SIZE = 1000; + + private final PermissionResource resource; + @Nullable private final String principal; + @Nullable private final String access; + @Nullable private final String pageToken; + @Nullable private final Integer maxResults; + + public ListPermissionsRequest( + ResourceType resourceType, + @Nullable String database, + @Nullable String table, + @Nullable String function, + @Nullable String view, + @Nullable String principal, + @Nullable String access, + @Nullable String pageToken, + @Nullable Integer maxResults) { + this.resource = exactResource(resourceType, database, table, function, view); + if (!isBlank(principal)) { + PermissionAssignment.validatePrincipal(principal); + } + checkArgument(maxResults == null || maxResults > 0, "maxResults must be greater than 0."); + checkArgument( + maxResults == null || maxResults <= MAX_PAGE_SIZE, + "maxResults must be at most %s.", + MAX_PAGE_SIZE); + this.principal = isBlank(principal) ? null : principal; + this.access = isBlank(access) ? null : PermissionAccess.canonicalize(resource, access); + this.pageToken = pageToken; + this.maxResults = maxResults; + } + + public ResourceType getResourceType() { + return resource.getType(); + } + + @Nullable + public String getDatabase() { + return resource.getDatabase(); + } + + @Nullable + public String getTable() { + return resource.getTable(); + } + + @Nullable + public String getFunction() { + return resource.getFunction(); + } + + @Nullable + public String getView() { + return resource.getView(); + } + + @Nullable + public String getPrincipal() { + return principal; + } + + @Nullable + public String getAccess() { + return access; + } + + @Nullable + public String getPageToken() { + return pageToken; + } + + @Nullable + public Integer getMaxResults() { + return maxResults; + } + + public PermissionResource resource() { + return resource; + } + + public ListPermissionsRequest withPageToken(@Nullable String newPageToken) { + return new ListPermissionsRequest( + resource.getType(), + resource.getDatabase(), + resource.getTable(), + resource.getFunction(), + resource.getView(), + principal, + access, + newPageToken, + maxResults); + } + + private static PermissionResource exactResource( + ResourceType resourceType, + @Nullable String database, + @Nullable String table, + @Nullable String function, + @Nullable String view) { + checkNotNull(resourceType, "resourceType cannot be null"); + try { + return new PermissionResource(resourceType, database, table, function, view); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Permission listing requires an exact target resource: " + e.getMessage(), e); + } + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/management/PermissionAccess.java b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAccess.java new file mode 100644 index 000000000000..742f6c04834b --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAccess.java @@ -0,0 +1,130 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.annotation.Experimental; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static org.apache.paimon.utils.Preconditions.checkArgument; +import static org.apache.paimon.utils.Preconditions.checkNotNull; + +/** Built-in access names and validation for permission assignments. */ +@Experimental +public final class PermissionAccess { + + /** Maximum wire length supported by the portable permission storage contract. */ + public static final int MAX_LENGTH = 32; + + public static final String ALL = "ALL"; + public static final String CREATEDATABASE = "CREATEDATABASE"; + public static final String DESCRIBE = "DESCRIBE"; + public static final String ALTER = "ALTER"; + public static final String DROP = "DROP"; + public static final String CREATETABLE = "CREATETABLE"; + public static final String CREATEFUNCTION = "CREATEFUNCTION"; + public static final String CREATEVIEW = "CREATEVIEW"; + public static final String LIST = "LIST"; + public static final String SELECT = "SELECT"; + public static final String UPDATE = "UPDATE"; + public static final String GRANT = "GRANT"; + + private static final Map> BUILT_INS = builtIns(); + + private PermissionAccess() {} + + public static String canonicalize(String access) { + checkArgument(access != null && !access.trim().isEmpty(), "access cannot be empty."); + checkArgument( + access.length() <= MAX_LENGTH, + "access must contain at most %s characters.", + MAX_LENGTH); + String canonical = access.toUpperCase(Locale.ROOT); + checkArgument( + canonical.length() <= MAX_LENGTH, + "access must contain at most %s characters after canonicalization.", + MAX_LENGTH); + if (BUILT_INS.values().stream().anyMatch(values -> values.contains(canonical))) { + return canonical; + } + throw new IllegalArgumentException(String.format("Unknown access '%s'.", canonical)); + } + + public static String canonicalize(PermissionResource resource, String access) { + checkNotNull(resource, "resource cannot be null"); + String canonical = canonicalize(access); + checkArgument( + BUILT_INS.get(resource.getType()).contains(canonical), + "Access '%s' is not valid for %s.", + canonical, + resource.getType()); + return canonical; + } + + public static Set builtIns(ResourceType type) { + return BUILT_INS.get(checkNotNull(type, "resource type cannot be null")); + } + + private static Map> builtIns() { + Map> accesses = new EnumMap<>(ResourceType.class); + accesses.put(ResourceType.CATALOG, values(ALL, ALTER, DROP, GRANT, CREATEDATABASE)); + accesses.put( + ResourceType.CATALOG_ALL, + values( + ALL, + DESCRIBE, + ALTER, + DROP, + GRANT, + CREATETABLE, + CREATEVIEW, + CREATEFUNCTION, + LIST, + SELECT, + UPDATE)); + accesses.put( + ResourceType.DATABASE, + values( + ALL, + DESCRIBE, + ALTER, + DROP, + GRANT, + CREATETABLE, + CREATEVIEW, + CREATEFUNCTION, + LIST)); + accesses.put(ResourceType.DATABASE_ALL, values(ALL, SELECT, UPDATE, ALTER, DROP, GRANT)); + accesses.put(ResourceType.TABLE, values(ALL, SELECT, UPDATE, ALTER, DROP, GRANT)); + accesses.put(ResourceType.COLUMN, values(SELECT)); + accesses.put(ResourceType.VIEW, values(ALL, SELECT, ALTER, DROP, GRANT)); + accesses.put(ResourceType.FUNCTION, values(ALL, SELECT, ALTER, DROP, GRANT)); + return Collections.unmodifiableMap(accesses); + } + + private static Set values(String... accesses) { + return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(accesses))); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/management/PermissionAssignment.java b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAssignment.java new file mode 100644 index 000000000000..9744ce83864f --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/PermissionAssignment.java @@ -0,0 +1,191 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.annotation.Experimental; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nullable; + +import java.beans.ConstructorProperties; +import java.time.Instant; +import java.time.format.DateTimeParseException; + +import static org.apache.paimon.utils.Preconditions.checkArgument; +import static org.apache.paimon.utils.Preconditions.checkNotNull; + +/** + * Direct permission assignment on one exact resource or explicit descendant scope. + * + *

A {@link ResourceType#COLUMN COLUMN} assignment carries one {@link PermissionColumns} value. + * The column range is mutable assignment content rather than identity: granting the same resource, + * access, and principal replaces the complete range. + * + *

{@code expireTime}, when present, is an exclusive authorization upper bound evaluated against + * the server clock. At or after that instant, the assignment must not authorize access, although an + * expired record may remain listable until cleanup. + */ +@Experimental +@JsonIgnoreProperties(ignoreUnknown = true) +public class PermissionAssignment { + + /** Maximum principal identifier length in the portable REST management contract. */ + public static final int MAX_PRINCIPAL_LENGTH = 128; + + private static final String FIELD_RESOURCE = "resource"; + private static final String FIELD_ACCESS = "access"; + private static final String FIELD_PRINCIPAL = "principal"; + private static final String FIELD_COLUMNS = "columns"; + private static final String FIELD_EXPIRE_TIME = "expireTime"; + + @JsonProperty(FIELD_RESOURCE) + private final PermissionResource resource; + + @JsonProperty(FIELD_ACCESS) + private final String access; + + @JsonProperty(FIELD_PRINCIPAL) + private final String principal; + + @Nullable + @JsonProperty(FIELD_COLUMNS) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final PermissionColumns columns; + + @Nullable + @JsonProperty(FIELD_EXPIRE_TIME) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String expireTime; + + public PermissionAssignment( + @JsonProperty(FIELD_RESOURCE) PermissionResource resource, + @JsonProperty(FIELD_ACCESS) String access, + @JsonProperty(FIELD_PRINCIPAL) String principal, + @Nullable @JsonProperty(FIELD_COLUMNS) PermissionColumns columns, + @Nullable @JsonProperty(FIELD_EXPIRE_TIME) String expireTime) { + this(resource, access, principal, columns, expireTime, true); + } + + public PermissionAssignment( + PermissionResource resource, + String access, + String principal, + @Nullable String expireTime) { + this(resource, access, principal, null, expireTime); + } + + /** Jackson-only response constructor; request DTOs use the validated public constructor. */ + @JsonCreator + @ConstructorProperties({ + FIELD_RESOURCE, + FIELD_ACCESS, + FIELD_PRINCIPAL, + FIELD_COLUMNS, + FIELD_EXPIRE_TIME + }) + PermissionAssignment( + @JsonProperty(FIELD_RESOURCE) PermissionResource resource, + @JsonProperty(FIELD_ACCESS) String access, + @JsonProperty(FIELD_PRINCIPAL) String principal, + @Nullable @JsonProperty(FIELD_COLUMNS) PermissionColumns columns, + @Nullable @JsonProperty(FIELD_EXPIRE_TIME) Object expireTime) { + this(resource, access, principal, columns, (String) expireTime, false); + } + + @JsonGetter(FIELD_RESOURCE) + public PermissionResource getResource() { + return resource; + } + + @JsonGetter(FIELD_ACCESS) + public String getAccess() { + return access; + } + + @JsonGetter(FIELD_PRINCIPAL) + public String getPrincipal() { + return principal; + } + + /** Validates and returns an opaque principal identifier. */ + public static String validatePrincipal(String principal) { + checkArgument( + principal != null && !principal.trim().isEmpty(), "principal cannot be empty."); + checkArgument( + principal.length() <= MAX_PRINCIPAL_LENGTH, + "principal must contain at most %s characters.", + MAX_PRINCIPAL_LENGTH); + return principal; + } + + /** Returns the selected or excluded column range for a COLUMN assignment. */ + @Nullable + @JsonGetter(FIELD_COLUMNS) + public PermissionColumns getColumns() { + return columns; + } + + /** Returns the exclusive authorization upper bound, or null for no expiry. */ + @Nullable + @JsonGetter(FIELD_EXPIRE_TIME) + public String getExpireTime() { + return expireTime; + } + + private PermissionAssignment( + PermissionResource resource, + String access, + String principal, + @Nullable PermissionColumns columns, + @Nullable String expireTime, + boolean validate) { + this.resource = validate ? checkNotNull(resource, "resource cannot be null") : resource; + this.access = validate ? PermissionAccess.canonicalize(resource, access) : access; + this.principal = validate ? validatePrincipal(principal) : principal; + if (validate) { + checkArgument( + resource.getType() == ResourceType.COLUMN ? columns != null : columns == null, + resource.getType() == ResourceType.COLUMN + ? "columns is required for COLUMN resource." + : "columns is only valid for COLUMN resource."); + validateExpireTime(expireTime); + } + this.columns = columns; + this.expireTime = expireTime; + } + + private static void validateExpireTime(@Nullable String expireTime) { + if (expireTime == null) { + return; + } + try { + Instant instant = Instant.parse(expireTime); + checkArgument( + instant.getNano() % 1_000_000 == 0, + "expireTime must have at most millisecond precision."); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("expireTime must be an ISO-8601 UTC instant.", e); + } + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/management/PermissionColumns.java b/paimon-api/src/main/java/org/apache/paimon/management/PermissionColumns.java new file mode 100644 index 000000000000..105d4fb57500 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/PermissionColumns.java @@ -0,0 +1,124 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.annotation.Experimental; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nullable; + +import java.beans.ConstructorProperties; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** + * Included or excluded top-level columns of a column-level permission assignment. + * + *

Exactly one list is present. Included names form an allowlist; excluded names form a denylist. + */ +@Experimental +@JsonIgnoreProperties(ignoreUnknown = true) +public class PermissionColumns { + + private static final String FIELD_COLUMN_NAMES = "columnNames"; + private static final String FIELD_EXCLUDED_COLUMN_NAMES = "excludedColumnNames"; + + @Nullable + @JsonProperty(FIELD_COLUMN_NAMES) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final List columnNames; + + @Nullable + @JsonProperty(FIELD_EXCLUDED_COLUMN_NAMES) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final List excludedColumnNames; + + @JsonCreator + @ConstructorProperties({FIELD_COLUMN_NAMES, FIELD_EXCLUDED_COLUMN_NAMES}) + public PermissionColumns( + @Nullable @JsonProperty(FIELD_COLUMN_NAMES) List columnNames, + @Nullable @JsonProperty(FIELD_EXCLUDED_COLUMN_NAMES) List excludedColumnNames) { + checkArgument( + (columnNames == null) != (excludedColumnNames == null), + "columns must contain exactly one of columnNames or excludedColumnNames."); + this.columnNames = immutableNonEmpty(columnNames, FIELD_COLUMN_NAMES); + this.excludedColumnNames = + immutableNonEmpty(excludedColumnNames, FIELD_EXCLUDED_COLUMN_NAMES); + } + + @Nullable + @JsonGetter(FIELD_COLUMN_NAMES) + public List getColumnNames() { + return columnNames; + } + + @Nullable + @JsonGetter(FIELD_EXCLUDED_COLUMN_NAMES) + public List getExcludedColumnNames() { + return excludedColumnNames; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PermissionColumns)) { + return false; + } + PermissionColumns that = (PermissionColumns) o; + return Objects.equals(columnNames, that.columnNames) + && Objects.equals(excludedColumnNames, that.excludedColumnNames); + } + + @Override + public int hashCode() { + return Objects.hash(columnNames, excludedColumnNames); + } + + @Nullable + private static List immutableNonEmpty( + @Nullable List columns, String fieldName) { + if (columns == null) { + return null; + } + checkArgument(!columns.isEmpty(), "%s cannot be empty.", fieldName); + for (String column : columns) { + checkArgument( + column != null && !column.trim().isEmpty(), + "%s cannot contain an empty column name.", + fieldName); + } + checkArgument( + new HashSet<>(columns).size() == columns.size(), + "%s cannot contain duplicate column names.", + fieldName); + return Collections.unmodifiableList(new ArrayList<>(columns)); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/management/PermissionManagement.java b/paimon-api/src/main/java/org/apache/paimon/management/PermissionManagement.java new file mode 100644 index 000000000000..3eb669c142d8 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/PermissionManagement.java @@ -0,0 +1,33 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.PagedList; +import org.apache.paimon.annotation.Experimental; + +/** Control-plane contract for managing permissions on catalog resources. */ +@Experimental +public interface PermissionManagement { + + PagedList listPermissions(ListPermissionsRequest request); + + void grantPermission(PermissionAssignment assignment); + + void revokePermission(PermissionResource resource, String access, String principal); +} diff --git a/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java b/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java new file mode 100644 index 000000000000..f0100af10096 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/PermissionResource.java @@ -0,0 +1,204 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.annotation.Experimental; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nullable; + +import java.beans.ConstructorProperties; +import java.util.Objects; + +import static org.apache.paimon.utils.Preconditions.checkArgument; +import static org.apache.paimon.utils.Preconditions.checkNotNull; + +/** Structured reference to a resource or explicit descendant scope inside the REST catalog. */ +@Experimental +@JsonIgnoreProperties(ignoreUnknown = true) +public class PermissionResource { + + private static final String FIELD_TYPE = "type"; + private static final String FIELD_DATABASE = "database"; + private static final String FIELD_TABLE = "table"; + private static final String FIELD_FUNCTION = "function"; + private static final String FIELD_VIEW = "view"; + + @JsonProperty(FIELD_TYPE) + private final ResourceType type; + + @Nullable + @JsonProperty(FIELD_DATABASE) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String database; + + @Nullable + @JsonProperty(FIELD_TABLE) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String table; + + @Nullable + @JsonProperty(FIELD_FUNCTION) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String function; + + @Nullable + @JsonProperty(FIELD_VIEW) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String view; + + @JsonCreator + @ConstructorProperties({FIELD_TYPE, FIELD_DATABASE, FIELD_TABLE, FIELD_FUNCTION, FIELD_VIEW}) + public PermissionResource( + @JsonProperty(FIELD_TYPE) String type, + @Nullable @JsonProperty(FIELD_DATABASE) String database, + @Nullable @JsonProperty(FIELD_TABLE) String table, + @Nullable @JsonProperty(FIELD_FUNCTION) String function, + @Nullable @JsonProperty(FIELD_VIEW) String view) { + this(ResourceType.fromString(type), database, table, function, view); + } + + public PermissionResource( + ResourceType type, + @Nullable String database, + @Nullable String table, + @Nullable String function, + @Nullable String view) { + this.type = checkNotNull(type, "resource type cannot be null"); + validate(type, database, table, function, view); + this.database = blankToNull(database); + this.table = blankToNull(table); + this.function = blankToNull(function); + this.view = blankToNull(view); + } + + @JsonGetter(FIELD_TYPE) + public ResourceType getType() { + return type; + } + + @Nullable + @JsonGetter(FIELD_DATABASE) + public String getDatabase() { + return database; + } + + @Nullable + @JsonGetter(FIELD_TABLE) + public String getTable() { + return table; + } + + @Nullable + @JsonGetter(FIELD_FUNCTION) + public String getFunction() { + return function; + } + + @Nullable + @JsonGetter(FIELD_VIEW) + public String getView() { + return view; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PermissionResource)) { + return false; + } + PermissionResource that = (PermissionResource) o; + return type == that.type + && Objects.equals(database, that.database) + && Objects.equals(table, that.table) + && Objects.equals(function, that.function) + && Objects.equals(view, that.view); + } + + @Override + public int hashCode() { + return Objects.hash(type, database, table, function, view); + } + + private static void validate( + ResourceType type, + @Nullable String database, + @Nullable String table, + @Nullable String function, + @Nullable String view) { + switch (type) { + case CATALOG: + case CATALOG_ALL: + checkArgument( + isBlank(database) && isBlank(table) && isBlank(function) && isBlank(view), + "%s resource cannot contain object identifiers.", + type); + break; + case DATABASE: + case DATABASE_ALL: + checkArgument(!isBlank(database), "database is required for %s resource.", type); + checkArgument( + isBlank(table) && isBlank(function) && isBlank(view), + "%s resource cannot contain table, function, or view.", + type); + break; + case TABLE: + case COLUMN: + checkArgument(!isBlank(database), "database is required for %s resource.", type); + checkArgument(!isBlank(table), "table is required for %s resource.", type); + checkArgument( + isBlank(function) && isBlank(view), + "%s resource cannot contain function or view.", + type); + break; + case FUNCTION: + checkArgument(!isBlank(database), "database is required for FUNCTION resource."); + checkArgument(!isBlank(function), "function is required for FUNCTION resource."); + checkArgument( + isBlank(table) && isBlank(view), + "FUNCTION resource cannot contain table or view."); + break; + case VIEW: + checkArgument(!isBlank(database), "database is required for VIEW resource."); + checkArgument(!isBlank(view), "view is required for VIEW resource."); + checkArgument( + isBlank(table) && isBlank(function), + "VIEW resource cannot contain table or function."); + break; + default: + throw new IllegalArgumentException("Unsupported resource type " + type); + } + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.trim().isEmpty(); + } + + @Nullable + private static String blankToNull(@Nullable String value) { + return isBlank(value) ? null : value; + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/management/ResourceType.java b/paimon-api/src/main/java/org/apache/paimon/management/ResourceType.java new file mode 100644 index 000000000000..ef38471ef67b --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/ResourceType.java @@ -0,0 +1,43 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.annotation.Experimental; + +import javax.annotation.Nullable; + +import java.util.Locale; + +/** Resource types and explicit descendant scopes supported by permission management. */ +@Experimental +public enum ResourceType { + CATALOG, + CATALOG_ALL, + DATABASE, + DATABASE_ALL, + TABLE, + COLUMN, + VIEW, + FUNCTION; + + @Nullable + public static ResourceType fromString(@Nullable String value) { + return value == null ? null : valueOf(value.toUpperCase(Locale.ROOT)); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java index 238a3969ff04..e2f5cc370259 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java @@ -20,11 +20,15 @@ import org.apache.paimon.PagedList; import org.apache.paimon.Snapshot; +import org.apache.paimon.annotation.Experimental; import org.apache.paimon.annotation.Public; import org.apache.paimon.annotation.VisibleForTesting; import org.apache.paimon.catalog.Identifier; import org.apache.paimon.consumer.ConsumerInfo; import org.apache.paimon.function.FunctionChange; +import org.apache.paimon.management.ListPermissionsRequest; +import org.apache.paimon.management.PermissionAssignment; +import org.apache.paimon.management.PermissionResource; import org.apache.paimon.options.Options; import org.apache.paimon.partition.Partition; import org.apache.paimon.partition.PartitionStatistics; @@ -48,6 +52,7 @@ import org.apache.paimon.rest.requests.CreateViewRequest; import org.apache.paimon.rest.requests.DropPartitionsRequest; import org.apache.paimon.rest.requests.ForwardBranchRequest; +import org.apache.paimon.rest.requests.GrantPermissionRequest; import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest; import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest; import org.apache.paimon.rest.requests.MarkDonePartitionsRequest; @@ -55,6 +60,7 @@ import org.apache.paimon.rest.requests.RenameTableRequest; import org.apache.paimon.rest.requests.ReplaceTableRequest; import org.apache.paimon.rest.requests.ResetConsumerRequest; +import org.apache.paimon.rest.requests.RevokePermissionRequest; import org.apache.paimon.rest.requests.RollbackSchemaRequest; import org.apache.paimon.rest.requests.RollbackTableRequest; import org.apache.paimon.rest.responses.AlterDatabaseResponse; @@ -79,6 +85,7 @@ import org.apache.paimon.rest.responses.ListFunctionsGloballyResponse; import org.apache.paimon.rest.responses.ListFunctionsResponse; import org.apache.paimon.rest.responses.ListPartitionsResponse; +import org.apache.paimon.rest.responses.ListPermissionsResponse; import org.apache.paimon.rest.responses.ListSnapshotsResponse; import org.apache.paimon.rest.responses.ListTableDetailsResponse; import org.apache.paimon.rest.responses.ListTablesGloballyResponse; @@ -833,6 +840,46 @@ public AuthTableQueryResponse authTableQuery( restAuthFunction); } + /** Lists permissions on an exact resource in the configured REST catalog. */ + @Experimental + public ListPermissionsResponse listPermissions(ListPermissionsRequest request) { + Map queryParams = Maps.newHashMap(); + putQueryParameter(queryParams, "resourceType", request.getResourceType().name()); + putQueryParameter(queryParams, "database", request.getDatabase()); + putQueryParameter(queryParams, "table", request.getTable()); + putQueryParameter(queryParams, "function", request.getFunction()); + putQueryParameter(queryParams, "view", request.getView()); + putQueryParameter(queryParams, "principal", request.getPrincipal()); + putQueryParameter(queryParams, "access", request.getAccess()); + if (request.getMaxResults() != null) { + queryParams.put(MAX_RESULTS, request.getMaxResults().toString()); + } + putQueryParameter(queryParams, PAGE_TOKEN, request.getPageToken()); + return client.get( + resourcePaths.permissions(), + queryParams, + ListPermissionsResponse.class, + restAuthFunction); + } + + /** Grants a permission for the configured REST catalog. */ + @Experimental + public void grantPermission(PermissionAssignment assignment) { + client.post( + resourcePaths.grantPermission(), + new GrantPermissionRequest(assignment), + restAuthFunction); + } + + /** Revokes a permission for the configured REST catalog. */ + @Experimental + public void revokePermission(PermissionResource resource, String access, String principal) { + client.post( + resourcePaths.revokePermission(), + new RevokePermissionRequest(resource, access, principal), + restAuthFunction); + } + /** * Drop table. * @@ -1727,4 +1774,11 @@ private final Map buildPagedQueryParams( RESTAuthFunction authFunction() { return restAuthFunction; } + + private static void putQueryParameter( + Map queryParams, String name, @Nullable String value) { + if (StringUtils.isNotEmpty(value)) { + queryParams.put(name, value); + } + } } diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTPermissionManagement.java b/paimon-api/src/main/java/org/apache/paimon/rest/RESTPermissionManagement.java new file mode 100644 index 000000000000..3091d1ac092a --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTPermissionManagement.java @@ -0,0 +1,54 @@ +/* + * 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.paimon.rest; + +import org.apache.paimon.PagedList; +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.management.ListPermissionsRequest; +import org.apache.paimon.management.PermissionAssignment; +import org.apache.paimon.management.PermissionManagement; +import org.apache.paimon.management.PermissionResource; +import org.apache.paimon.rest.responses.ListPermissionsResponse; + +/** REST implementation of permission management, bound to the configured REST catalog prefix. */ +@Experimental +public class RESTPermissionManagement implements PermissionManagement { + + private final RESTApi api; + + public RESTPermissionManagement(RESTApi api) { + this.api = api; + } + + @Override + public PagedList listPermissions(ListPermissionsRequest request) { + ListPermissionsResponse response = api.listPermissions(request); + return new PagedList<>(response.getPermissions(), response.getNextPageToken()); + } + + @Override + public void grantPermission(PermissionAssignment assignment) { + api.grantPermission(assignment); + } + + @Override + public void revokePermission(PermissionResource resource, String access, String principal) { + api.revokePermission(resource, access, principal); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java index ae09705467cc..6ae7ebf8c3ec 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java @@ -18,6 +18,7 @@ package org.apache.paimon.rest; +import org.apache.paimon.annotation.Experimental; import org.apache.paimon.options.Options; import org.apache.paimon.shade.guava30.com.google.common.base.Joiner; @@ -42,6 +43,7 @@ public class ResourcePaths { protected static final String REGISTER = "register"; protected static final String FUNCTIONS = "functions"; protected static final String FUNCTION_DETAILS = "function-details"; + protected static final String PERMISSIONS = "permissions"; protected static final String ID = "id"; private static final Joiner SLASH = Joiner.on("/").skipNulls(); @@ -60,6 +62,21 @@ public ResourcePaths(String prefix) { this.prefix = encodeString(prefix); } + @Experimental + public String permissions() { + return SLASH.join(V1, prefix, PERMISSIONS); + } + + @Experimental + public String grantPermission() { + return SLASH.join(permissions(), "grant"); + } + + @Experimental + public String revokePermission() { + return SLASH.join(permissions(), "revoke"); + } + public String databases() { return SLASH.join(V1, prefix, DATABASES); } diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/requests/GrantPermissionRequest.java b/paimon-api/src/main/java/org/apache/paimon/rest/requests/GrantPermissionRequest.java new file mode 100644 index 000000000000..377e997adb95 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/requests/GrantPermissionRequest.java @@ -0,0 +1,104 @@ +/* + * 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.paimon.rest.requests; + +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.management.PermissionAssignment; +import org.apache.paimon.management.PermissionColumns; +import org.apache.paimon.management.PermissionResource; +import org.apache.paimon.rest.RESTRequest; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nullable; + +import java.beans.ConstructorProperties; + +/** Request for granting or replacing a permission assignment. */ +@Experimental +@JsonIgnoreProperties(ignoreUnknown = true) +public class GrantPermissionRequest implements RESTRequest { + + private static final String FIELD_RESOURCE = "resource"; + private static final String FIELD_ACCESS = "access"; + private static final String FIELD_PRINCIPAL = "principal"; + private static final String FIELD_COLUMNS = "columns"; + private static final String FIELD_EXPIRE_TIME = "expireTime"; + + private final PermissionAssignment assignment; + + public GrantPermissionRequest(PermissionAssignment assignment) { + this.assignment = assignment; + } + + @JsonCreator + @ConstructorProperties({ + FIELD_RESOURCE, + FIELD_ACCESS, + FIELD_PRINCIPAL, + FIELD_COLUMNS, + FIELD_EXPIRE_TIME + }) + public GrantPermissionRequest( + @JsonProperty(FIELD_RESOURCE) PermissionResource resource, + @JsonProperty(FIELD_ACCESS) String access, + @JsonProperty(FIELD_PRINCIPAL) String principal, + @Nullable @JsonProperty(FIELD_COLUMNS) PermissionColumns columns, + @Nullable @JsonProperty(FIELD_EXPIRE_TIME) String expireTime) { + this.assignment = + new PermissionAssignment(resource, access, principal, columns, expireTime); + } + + public PermissionAssignment assignment() { + return assignment; + } + + @JsonGetter(FIELD_RESOURCE) + public PermissionResource getResource() { + return assignment.getResource(); + } + + @JsonGetter(FIELD_ACCESS) + public String getAccess() { + return assignment.getAccess(); + } + + @JsonGetter(FIELD_PRINCIPAL) + public String getPrincipal() { + return assignment.getPrincipal(); + } + + @Nullable + @JsonGetter(FIELD_COLUMNS) + @JsonInclude(JsonInclude.Include.NON_NULL) + public PermissionColumns getColumns() { + return assignment.getColumns(); + } + + @Nullable + @JsonGetter(FIELD_EXPIRE_TIME) + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getExpireTime() { + return assignment.getExpireTime(); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/requests/RevokePermissionRequest.java b/paimon-api/src/main/java/org/apache/paimon/rest/requests/RevokePermissionRequest.java new file mode 100644 index 000000000000..0da87420fdcc --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/requests/RevokePermissionRequest.java @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest.requests; + +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.management.PermissionAccess; +import org.apache.paimon.management.PermissionResource; +import org.apache.paimon.rest.RESTRequest; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import java.beans.ConstructorProperties; + +/** Idempotent request for revoking a permission assignment by identity. */ +@Experimental +@JsonIgnoreProperties(ignoreUnknown = true) +public class RevokePermissionRequest implements RESTRequest { + + private static final String FIELD_RESOURCE = "resource"; + private static final String FIELD_ACCESS = "access"; + private static final String FIELD_PRINCIPAL = "principal"; + + private final PermissionResource resource; + private final String access; + private final String principal; + + @JsonCreator + @ConstructorProperties({FIELD_RESOURCE, FIELD_ACCESS, FIELD_PRINCIPAL}) + public RevokePermissionRequest( + @JsonProperty(FIELD_RESOURCE) PermissionResource resource, + @JsonProperty(FIELD_ACCESS) String access, + @JsonProperty(FIELD_PRINCIPAL) String principal) { + this.resource = resource; + this.access = PermissionAccess.canonicalize(resource, access); + this.principal = + org.apache.paimon.management.PermissionAssignment.validatePrincipal(principal); + } + + @JsonGetter(FIELD_RESOURCE) + public PermissionResource getResource() { + return resource; + } + + @JsonGetter(FIELD_ACCESS) + public String getAccess() { + return access; + } + + @JsonGetter(FIELD_PRINCIPAL) + public String getPrincipal() { + return principal; + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPermissionsResponse.java b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPermissionsResponse.java new file mode 100644 index 000000000000..ee4d86d1ed9c --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListPermissionsResponse.java @@ -0,0 +1,76 @@ +/* + * 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.paimon.rest.responses; + +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.management.PermissionAssignment; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nullable; + +import java.beans.ConstructorProperties; +import java.util.List; + +/** Response for listing permissions. */ +@Experimental +@JsonIgnoreProperties(ignoreUnknown = true) +public class ListPermissionsResponse implements PagedResponse { + + private static final String FIELD_PERMISSIONS = "permissions"; + private static final String FIELD_NEXT_PAGE_TOKEN = "nextPageToken"; + + @JsonProperty(FIELD_PERMISSIONS) + private final List permissions; + + @Nullable + @JsonProperty(FIELD_NEXT_PAGE_TOKEN) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String nextPageToken; + + @JsonCreator + @ConstructorProperties({FIELD_PERMISSIONS, FIELD_NEXT_PAGE_TOKEN}) + public ListPermissionsResponse( + @JsonProperty(FIELD_PERMISSIONS) List permissions, + @Nullable @JsonProperty(FIELD_NEXT_PAGE_TOKEN) String nextPageToken) { + this.permissions = permissions; + this.nextPageToken = nextPageToken; + } + + @JsonGetter(FIELD_PERMISSIONS) + public List getPermissions() { + return permissions; + } + + @Override + @Nullable + @JsonGetter(FIELD_NEXT_PAGE_TOKEN) + public String getNextPageToken() { + return nextPageToken; + } + + @Override + public List data() { + return permissions; + } +} diff --git a/paimon-api/src/test/java/org/apache/paimon/management/PermissionManagementJsonTest.java b/paimon-api/src/test/java/org/apache/paimon/management/PermissionManagementJsonTest.java new file mode 100644 index 000000000000..f05e32a21573 --- /dev/null +++ b/paimon-api/src/test/java/org/apache/paimon/management/PermissionManagementJsonTest.java @@ -0,0 +1,475 @@ +/* + * 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.paimon.management; + +import org.apache.paimon.rest.RESTApi; +import org.apache.paimon.rest.requests.GrantPermissionRequest; +import org.apache.paimon.rest.requests.RevokePermissionRequest; +import org.apache.paimon.rest.responses.ListPermissionsResponse; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** JSON and validation tests for permission management contracts. */ +public class PermissionManagementJsonTest { + + private static final String ASSIGNMENT_JSON = + "{\"resource\":{\"type\":\"TABLE\",\"database\":\"sales\"," + + "\"table\":\"orders\"},\"access\":\"SELECT\"," + + "\"principal\":\"analyst\"," + + "\"expireTime\":\"2027-01-01T00:00:00Z\"}"; + + private static final String COLUMN_ASSIGNMENT_JSON = + "{\"resource\":{\"type\":\"COLUMN\",\"database\":\"sales\"," + + "\"table\":\"orders\"},\"access\":\"SELECT\"," + + "\"principal\":\"analyst\",\"columns\":{" + + "\"columnNames\":[\"id\",\"region\"]}}"; + + @Test + void testAssignmentDeserializesWithShadedAndExternalJackson() throws Exception { + assertAssignment(RESTApi.fromJson(ASSIGNMENT_JSON, PermissionAssignment.class)); + assertAssignment( + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(ASSIGNMENT_JSON, PermissionAssignment.class)); + } + + @Test + void testAssignmentDeserializesLowerCaseResourceType() throws Exception { + String lowerCaseJson = ASSIGNMENT_JSON.replace("\"TABLE\"", "\"table\""); + + assertAssignment(RESTApi.fromJson(lowerCaseJson, PermissionAssignment.class)); + assertAssignment( + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(lowerCaseJson, PermissionAssignment.class)); + } + + @Test + void testListResponseDoesNotApplyGrantValidation() throws Exception { + String preciseExpiry = "2027-01-01T00:00:00.123456Z"; + String responseJson = + "{\"permissions\":[" + + ASSIGNMENT_JSON.replace("2027-01-01T00:00:00Z", preciseExpiry) + + "]}"; + + ListPermissionsResponse shaded = + RESTApi.fromJson(responseJson, ListPermissionsResponse.class); + ListPermissionsResponse external = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(responseJson, ListPermissionsResponse.class); + + assertThat(shaded.getPermissions().get(0).getExpireTime()).isEqualTo(preciseExpiry); + assertThat(external.getPermissions().get(0).getExpireTime()).isEqualTo(preciseExpiry); + assertThatThrownBy( + () -> + new GrantPermissionRequest( + tableResource(), "SELECT", "analyst", null, preciseExpiry)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("millisecond"); + } + + @Test + void testColumnAssignmentRoundTripsWithShadedAndExternalJackson() throws Exception { + PermissionAssignment shaded = + RESTApi.fromJson(COLUMN_ASSIGNMENT_JSON, PermissionAssignment.class); + PermissionAssignment external = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(COLUMN_ASSIGNMENT_JSON, PermissionAssignment.class); + + for (PermissionAssignment assignment : Arrays.asList(shaded, external)) { + assertThat(assignment.getResource().getType()).isEqualTo(ResourceType.COLUMN); + assertThat(assignment.getAccess()).isEqualTo("SELECT"); + assertThat(assignment.getColumns().getColumnNames()).containsExactly("id", "region"); + assertThat(assignment.getColumns().getExcludedColumnNames()).isNull(); + } + + Map wire = RESTApi.fromJson(RESTApi.toJson(shaded), Map.class); + assertThat(((Map) wire.get("columns")).get("columnNames")) + .isEqualTo(Arrays.asList("id", "region")); + Map externalWire = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue( + new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(external), + Map.class); + Map externalColumns = (Map) externalWire.get("columns"); + assertThat(externalColumns.get("columnNames")).isEqualTo(Arrays.asList("id", "region")); + assertThat(externalColumns.get("excludedColumnNames")).isNull(); + } + + @Test + void testGrantAndRevokeUsePrivilegeOnlyWireShapes() throws Exception { + PermissionAssignment assignment = + RESTApi.fromJson(ASSIGNMENT_JSON, PermissionAssignment.class); + Map grant = + RESTApi.fromJson(RESTApi.toJson(new GrantPermissionRequest(assignment)), Map.class); + + assertThat(grant.get("access")).isEqualTo("SELECT"); + assertThat(grant.containsKey("columns")).isFalse(); + assertThat(grant.containsKey("policy")).isFalse(); + assertThat(grant.containsKey("grantOption")).isFalse(); + + Map revoke = + RESTApi.fromJson( + RESTApi.toJson( + new RevokePermissionRequest( + assignment.getResource(), + assignment.getAccess(), + assignment.getPrincipal())), + Map.class); + assertThat(revoke.get("access")).isEqualTo("SELECT"); + assertThat(revoke.containsKey("columns")).isFalse(); + assertThat(revoke.containsKey("policy")).isFalse(); + assertThat(revoke.containsKey("policyType")).isFalse(); + assertThat(revoke.containsKey("grantOption")).isFalse(); + assertThat(revoke.containsKey("expireTime")).isFalse(); + } + + @Test + void testPermissionRequestWithoutExpiry() throws Exception { + String permissionJson = + "{\"resource\":{\"type\":\"TABLE\",\"database\":\"sales\"," + + "\"table\":\"orders\"},\"access\":\"select\"," + + "\"principal\":\"analyst\"}"; + + assertThat(RESTApi.fromJson(permissionJson, GrantPermissionRequest.class).getExpireTime()) + .isNull(); + assertThat(RESTApi.fromJson(permissionJson, RevokePermissionRequest.class).getAccess()) + .isEqualTo("SELECT"); + } + + @Test + void testAccessAndPermissionValidation() { + assertThat( + new PermissionAssignment( + catalogResource(), "createdatabase", "analyst", null) + .getAccess()) + .isEqualTo("CREATEDATABASE"); + assertThat( + new PermissionAssignment(databaseResource(), "createview", "analyst", null) + .getAccess()) + .isEqualTo("CREATEVIEW"); + assertThat( + new PermissionAssignment(functionResource(), "select", "analyst", null) + .getAccess()) + .isEqualTo("SELECT"); + assertThat(PermissionAccess.builtIns(ResourceType.CATALOG)) + .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "GRANT", "CREATEDATABASE"); + assertThat(PermissionAccess.builtIns(ResourceType.CATALOG_ALL)) + .containsExactlyInAnyOrder( + "ALL", + "DESCRIBE", + "ALTER", + "DROP", + "GRANT", + "CREATETABLE", + "CREATEVIEW", + "CREATEFUNCTION", + "LIST", + "SELECT", + "UPDATE"); + assertThat(PermissionAccess.builtIns(ResourceType.DATABASE)) + .containsExactlyInAnyOrder( + "ALL", + "DESCRIBE", + "ALTER", + "DROP", + "GRANT", + "CREATETABLE", + "CREATEVIEW", + "CREATEFUNCTION", + "LIST"); + assertThat(PermissionAccess.builtIns(ResourceType.DATABASE_ALL)) + .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT", "UPDATE", "GRANT"); + assertThat(PermissionAccess.builtIns(ResourceType.TABLE)) + .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT", "UPDATE", "GRANT"); + assertThat(PermissionAccess.builtIns(ResourceType.VIEW)) + .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT", "GRANT"); + assertThat(PermissionAccess.builtIns(ResourceType.FUNCTION)) + .containsExactlyInAnyOrder("ALL", "ALTER", "DROP", "SELECT", "GRANT"); + assertThat(PermissionAccess.builtIns(ResourceType.COLUMN)).containsExactly("SELECT"); + + PermissionColumns included = new PermissionColumns(Arrays.asList("id", "region"), null); + assertThat( + new PermissionAssignment( + columnResource(), "select", "analyst", included, null) + .getColumns()) + .isEqualTo(included); + + assertThatThrownBy( + () -> + new PermissionAssignment( + catalogResource(), "SELECT", "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for CATALOG"); + assertThatThrownBy( + () -> + new PermissionAssignment( + databaseResource(), "SELECT", "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for DATABASE"); + assertThatThrownBy( + () -> + new PermissionAssignment( + catalogAllResource(), "CREATEDATABASE", "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for CATALOG_ALL"); + assertThatThrownBy( + () -> + new PermissionAssignment( + databaseAllResource(), "LIST", "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for DATABASE_ALL"); + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), "CREATEVIEW", "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for TABLE"); + assertThatThrownBy( + () -> + new PermissionAssignment( + functionResource(), "UPDATE", "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for FUNCTION"); + assertThatThrownBy( + () -> + new PermissionAssignment( + columnResource(), "UPDATE", "analyst", included, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for COLUMN"); + assertThatThrownBy( + () -> + new PermissionAssignment( + columnResource(), "SELECT", "analyst", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("columns is required"); + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), "SELECT", "analyst", included, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("only valid for COLUMN"); + assertThatThrownBy( + () -> + new PermissionColumns( + Collections.singletonList("id"), + Collections.singletonList("region"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one"); + assertThatThrownBy(() -> new PermissionColumns(Collections.emptyList(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be empty"); + assertThatThrownBy(() -> new PermissionColumns(Arrays.asList("id", "id"), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + assertThatThrownBy(() -> new PermissionColumns(Collections.singletonList(" "), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + for (String access : + Arrays.asList( + "USE_CATALOG", + "CREATE_DATABASE", + "USE_DATABASE", + "CREATE_TABLE", + "CREATE_VIEW", + "CREATE_FUNCTION", + "INSERT", + "DELETE", + "EXECUTE", + "MANAGE_PERMISSIONS", + "vendor.example/read_sensitive")) { + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), access, "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown access"); + } + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), + repeat('A', PermissionAccess.MAX_LENGTH + 1), + "analyst", + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("32"); + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), "a/" + repeat('ß', 16), "analyst", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("after canonicalization"); + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), + "SELECT", + repeat('p', PermissionAssignment.MAX_PRINCIPAL_LENGTH + 1), + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("128"); + assertThatThrownBy( + () -> + new PermissionAssignment( + tableResource(), + "SELECT", + "analyst", + "2027-01-01T00:00:00.000001Z")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("millisecond"); + } + + @Test + void testPermissionColumnsDefensivelyCopiesItsRange() { + java.util.List source = new ArrayList<>(Arrays.asList("id", "region")); + PermissionColumns columns = new PermissionColumns(source, null); + + source.clear(); + assertThat(columns.getColumnNames()).containsExactly("id", "region"); + assertThatThrownBy(() -> columns.getColumnNames().add("email")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testPermissionListRequiresExactResourceAndBoundsPageSize() { + assertThatThrownBy( + () -> + new ListPermissionsRequest( + ResourceType.TABLE, + "sales", + null, + null, + null, + null, + null, + null, + 25)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exact target"); + assertThatThrownBy( + () -> + new ListPermissionsRequest( + ResourceType.CATALOG, + null, + null, + null, + null, + null, + null, + null, + 1001)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at most 1000"); + + ListPermissionsRequest databaseAccess = + new ListPermissionsRequest( + ResourceType.DATABASE, + "sales", + null, + null, + null, + null, + "createview", + null, + 25); + assertThat(databaseAccess.getAccess()).isEqualTo("CREATEVIEW"); + assertThatThrownBy( + () -> + new ListPermissionsRequest( + ResourceType.DATABASE, + "sales", + null, + null, + null, + null, + "SELECT", + null, + 25)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid for DATABASE"); + + assertThat(databaseAccess.withPageToken(" \t").getPageToken()).isEqualTo(" \t"); + } + + @Test + void testResourceCanonicalizesBlankIrrelevantLocators() throws Exception { + PermissionResource catalog = + new PermissionResource(ResourceType.CATALOG, "", " ", null, null); + PermissionResource catalogAll = + new PermissionResource(ResourceType.CATALOG_ALL, "", " ", null, null); + + assertThat(catalog).isEqualTo(catalogResource()); + assertThat(RESTApi.toJson(catalog)).isEqualTo("{\"type\":\"CATALOG\"}"); + assertThat(catalogAll).isEqualTo(catalogAllResource()); + assertThat(RESTApi.toJson(catalogAll)).isEqualTo("{\"type\":\"CATALOG_ALL\"}"); + assertThat(RESTApi.toJson(databaseAllResource())) + .isEqualTo("{\"type\":\"DATABASE_ALL\",\"database\":\"sales\"}"); + } + + private static PermissionResource catalogResource() { + return new PermissionResource(ResourceType.CATALOG, null, null, null, null); + } + + private static PermissionResource catalogAllResource() { + return new PermissionResource(ResourceType.CATALOG_ALL, null, null, null, null); + } + + private static PermissionResource databaseResource() { + return new PermissionResource(ResourceType.DATABASE, "sales", null, null, null); + } + + private static PermissionResource databaseAllResource() { + return new PermissionResource(ResourceType.DATABASE_ALL, "sales", null, null, null); + } + + private static PermissionResource tableResource() { + return new PermissionResource(ResourceType.TABLE, "sales", "orders", null, null); + } + + private static PermissionResource columnResource() { + return new PermissionResource(ResourceType.COLUMN, "sales", "orders", null, null); + } + + private static PermissionResource functionResource() { + return new PermissionResource(ResourceType.FUNCTION, "sales", null, "calculate_tax", null); + } + + private static String repeat(char value, int length) { + char[] values = new char[length]; + Arrays.fill(values, value); + return new String(values); + } + + private static void assertAssignment(PermissionAssignment assignment) { + assertThat(assignment.getResource().getType()).isEqualTo(ResourceType.TABLE); + assertThat(assignment.getResource().getDatabase()).isEqualTo("sales"); + assertThat(assignment.getResource().getTable()).isEqualTo("orders"); + assertThat(assignment.getAccess()).isEqualTo("SELECT"); + assertThat(assignment.getPrincipal()).isEqualTo("analyst"); + assertThat(assignment.getExpireTime()).isEqualTo("2027-01-01T00:00:00Z"); + } +} diff --git a/paimon-api/src/test/java/org/apache/paimon/rest/RESTPermissionManagementTest.java b/paimon-api/src/test/java/org/apache/paimon/rest/RESTPermissionManagementTest.java new file mode 100644 index 000000000000..48d19ffa76a6 --- /dev/null +++ b/paimon-api/src/test/java/org/apache/paimon/rest/RESTPermissionManagementTest.java @@ -0,0 +1,251 @@ +/* + * 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.paimon.rest; + +import org.apache.paimon.PagedList; +import org.apache.paimon.management.ListPermissionsRequest; +import org.apache.paimon.management.PermissionAssignment; +import org.apache.paimon.management.PermissionColumns; +import org.apache.paimon.management.PermissionManagement; +import org.apache.paimon.management.PermissionResource; +import org.apache.paimon.management.ResourceType; +import org.apache.paimon.options.Options; +import org.apache.paimon.rest.exceptions.ForbiddenException; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.apache.paimon.rest.RESTCatalogInternalOptions.PREFIX; +import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN; +import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER; +import static org.apache.paimon.rest.RESTCatalogOptions.URI; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Behavioral tests for REST permission management. */ +public class RESTPermissionManagementTest { + + private static final String BASE_PATH = "/v1/catalog+id/permissions"; + + private HttpServer server; + private PermissionManagement management; + private final AtomicReference grantBody = new AtomicReference<>(); + private final AtomicReference revokeBody = new AtomicReference<>(); + private final AtomicReference authorization = new AtomicReference<>(); + private final AtomicReference listQuery = new AtomicReference<>(); + private final AtomicInteger revokeCalls = new AtomicInteger(); + + @BeforeEach + void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/v1/", + exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + String path = exchange.getRequestURI().getRawPath(); + if (BASE_PATH.equals(path) && "GET".equals(exchange.getRequestMethod())) { + listQuery.set(exchange.getRequestURI().getRawQuery()); + respond( + exchange, + 200, + "{\"permissions\":[{\"resource\":{\"type\":\"TABLE\"," + + "\"database\":\"sales\",\"table\":\"orders\"}," + + "\"access\":\"SELECT\"," + + "\"principal\":\"analyst\"}]," + + "\"nextPageToken\":\"next\"}"); + } else if ((BASE_PATH + "/grant").equals(path)) { + String body = readBody(exchange); + grantBody.set(body); + if (body.contains("denied")) { + respond(exchange, 403, "{\"message\":\"forbidden\",\"code\":403}"); + } else { + respond(exchange, 200, null); + } + } else if ((BASE_PATH + "/revoke").equals(path)) { + revokeBody.set(readBody(exchange)); + revokeCalls.incrementAndGet(); + respond(exchange, 200, null); + } else { + respond(exchange, 404, "{\"message\":\"missing\",\"code\":404}"); + } + }); + server.start(); + + Options options = new Options(); + options.set(URI, "http://127.0.0.1:" + server.getAddress().getPort()); + options.set(TOKEN_PROVIDER, "bear"); + options.set(TOKEN, "secret"); + options.set(PREFIX, "catalog id"); + management = new RESTPermissionManagement(new RESTApi(options, false)); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void testListUsesEncodedPrefixAndCompleteFilters() throws Exception { + PagedList page = + management.listPermissions( + new ListPermissionsRequest( + ResourceType.TABLE, + "sales", + "orders", + null, + null, + "analyst", + null, + "start", + 25)); + + assertThat(page.getElements()).hasSize(1); + assertThat(page.getElements().get(0).getPrincipal()).isEqualTo("analyst"); + assertThat(page.getNextPageToken()).isEqualTo("next"); + assertThat(queryParameters(listQuery.get())) + .containsEntry("principal", "analyst") + .containsEntry("resourceType", "TABLE") + .containsEntry("database", "sales") + .containsEntry("table", "orders") + .containsEntry("maxResults", "25") + .containsEntry("pageToken", "start"); + assertThat(authorization.get()).isEqualTo("Bearer secret"); + } + + @Test + void testGrantAndRevokeUseStructuredWireShapes() throws Exception { + PermissionAssignment assignment = assignment("analyst"); + management.grantPermission(assignment); + management.revokePermission(assignment.getResource(), "select", assignment.getPrincipal()); + + Map grant = RESTApi.fromJson(grantBody.get(), Map.class); + Map grantResource = (Map) grant.get("resource"); + assertThat(grantResource.get("type")).isEqualTo("TABLE"); + assertThat(grantResource.get("database")).isEqualTo("sales"); + assertThat(grantResource.get("table")).isEqualTo("orders"); + assertThat(grant.get("principal")).isEqualTo("analyst"); + assertThat(grant.containsKey("columns")).isFalse(); + assertThat(grant.containsKey("policy")).isFalse(); + assertThat(grant.containsKey("grantOption")).isFalse(); + assertThat(grant.containsKey("catalog")).isFalse(); + + Map revoke = RESTApi.fromJson(revokeBody.get(), Map.class); + Map revokeResource = (Map) revoke.get("resource"); + assertThat(revokeResource.get("type")).isEqualTo("TABLE"); + assertThat(revokeResource.get("database")).isEqualTo("sales"); + assertThat(revokeResource.get("table")).isEqualTo("orders"); + assertThat(revoke.get("access")).isEqualTo("SELECT"); + assertThat(revoke.get("principal")).isEqualTo("analyst"); + assertThat(revoke.containsKey("expireTime")).isFalse(); + assertThat(revoke.containsKey("grantOption")).isFalse(); + } + + @Test + void testForbiddenGrantPreservesRESTErrorTranslation() { + assertThatThrownBy(() -> management.grantPermission(assignment("denied"))) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("forbidden"); + } + + @Test + void testColumnGrantCarriesRangeButRevokeUsesOnlyIdentity() throws Exception { + PermissionAssignment assignment = + new PermissionAssignment( + new PermissionResource(ResourceType.COLUMN, "sales", "orders", null, null), + "SELECT", + "analyst", + new PermissionColumns(Arrays.asList("id", "region"), null), + null); + + management.grantPermission(assignment); + Map grant = RESTApi.fromJson(grantBody.get(), Map.class); + assertThat(((Map) grant.get("columns")).get("columnNames")) + .isEqualTo(Arrays.asList("id", "region")); + + management.revokePermission( + assignment.getResource(), assignment.getAccess(), assignment.getPrincipal()); + Map revoke = RESTApi.fromJson(revokeBody.get(), Map.class); + assertThat(((Map) revoke.get("resource")).get("type")).isEqualTo("COLUMN"); + assertThat(revoke.containsKey("columns")).isFalse(); + } + + @Test + void testRepeatedRevokeIsIdempotent() { + PermissionAssignment assignment = assignment("missing"); + management.revokePermission( + assignment.getResource(), assignment.getAccess(), assignment.getPrincipal()); + management.revokePermission( + assignment.getResource(), assignment.getAccess(), assignment.getPrincipal()); + + assertThat(revokeCalls).hasValue(2); + } + + private static PermissionAssignment assignment(String principal) { + return new PermissionAssignment( + new PermissionResource(ResourceType.TABLE, "sales", "orders", null, null), + "SELECT", + principal, + null); + } + + private static Map queryParameters(String query) throws Exception { + Map values = new HashMap<>(); + for (String parameter : query.split("&")) { + String[] pair = parameter.split("=", 2); + values.put(URLDecoder.decode(pair[0], "UTF-8"), URLDecoder.decode(pair[1], "UTF-8")); + } + return values; + } + + private static String readBody(HttpExchange exchange) throws IOException { + byte[] data = new byte[8192]; + int read = exchange.getRequestBody().read(data); + return read < 0 ? "" : new String(data, 0, read, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, int code, String body) throws IOException { + if (body == null) { + exchange.sendResponseHeaders(code, 0); + exchange.getResponseBody().close(); + } else { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(code, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + exchange.close(); + } + } +} diff --git a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java index 51bf7c8831f5..30ebab546325 100644 --- a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java +++ b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java @@ -169,9 +169,11 @@ public class RequestJacksonCompatibilityTest { CreatePartitionsRequest.class, CreateTableRequest.class, CreateViewRequest.class, + GrantPermissionRequest.class, RegisterTableRequest.class, RenameTableRequest.class, ReplaceTableRequest.class, + RevokePermissionRequest.class, RollbackTableRequest.class) .collect(Collectors.toSet()); diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java index a24fb2354912..3c668aeacb12 100644 --- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java @@ -22,6 +22,7 @@ import org.apache.paimon.PagedList; import org.apache.paimon.Snapshot; import org.apache.paimon.TableType; +import org.apache.paimon.annotation.Experimental; import org.apache.paimon.annotation.VisibleForTesting; import org.apache.paimon.catalog.Catalog; import org.apache.paimon.catalog.CatalogContext; @@ -39,6 +40,7 @@ import org.apache.paimon.fs.cache.LocalCacheManager; import org.apache.paimon.function.Function; import org.apache.paimon.function.FunctionChange; +import org.apache.paimon.management.PermissionManagement; import org.apache.paimon.options.Options; import org.apache.paimon.partition.Partition; import org.apache.paimon.partition.PartitionStatistics; @@ -137,6 +139,11 @@ public RESTCatalogLoader catalogLoader() { return new RESTCatalogLoader(context); } + @Experimental + public PermissionManagement permissionManagement() { + return new RESTPermissionManagement(api); + } + @Override public List listDatabases() { return api.listDatabases(); diff --git a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java b/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java index e3979c437a35..432ea8f77622 100644 --- a/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/rest/ResourcePathsTest.java @@ -39,4 +39,12 @@ public void testUrlEncode() { "/v1/paimon%2Faaaa/databases/test_db/tables/test_table%24snapshot", resourcePaths.table(database, objectName)); } + + @Test + public void testPermissionManagementUsesPrefix() { + ResourcePaths resourcePaths = new ResourcePaths("catalog/id"); + assertEquals("/v1/catalog%2Fid/permissions", resourcePaths.permissions()); + assertEquals("/v1/catalog%2Fid/permissions/grant", resourcePaths.grantPermission()); + assertEquals("/v1/catalog%2Fid/permissions/revoke", resourcePaths.revokePermission()); + } }