diff --git a/api/src/main/java/com/datastrato/gravitino/Group.java b/api/src/main/java/com/datastrato/gravitino/Group.java new file mode 100644 index 00000000000..571136e840a --- /dev/null +++ b/api/src/main/java/com/datastrato/gravitino/Group.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino; + +import java.util.List; +import java.util.Map; + +/** The interface of a Group. The Group is the entity which contains users. */ +public interface Group extends Auditable { + + /** + * The name of the group. + * + * @return The name of the group. + */ + String name(); + + /** + * The properties of the group. Note, this method will return null if the properties are not set. + * + * @return The properties of the group. + */ + Map properties(); + + /** + * The users of the group. + * + * @return The users of the group. + */ + List users(); +} diff --git a/api/src/main/java/com/datastrato/gravitino/exceptions/GroupAlreadyExistsException.java b/api/src/main/java/com/datastrato/gravitino/exceptions/GroupAlreadyExistsException.java new file mode 100644 index 00000000000..13c49b06d6c --- /dev/null +++ b/api/src/main/java/com/datastrato/gravitino/exceptions/GroupAlreadyExistsException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ + +package com.datastrato.gravitino.exceptions; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; + +/** An exception thrown when a resource already exists. */ +public class GroupAlreadyExistsException extends AlreadyExistsException { + + /** + * Constructs a new exception with the specified detail message. + * + * @param message the detail message. + * @param args the arguments to the message. + */ + @FormatMethod + public GroupAlreadyExistsException(@FormatString String message, Object... args) { + super(message, args); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param cause the cause. + * @param message the detail message. + * @param args the arguments to the message. + */ + @FormatMethod + public GroupAlreadyExistsException(Throwable cause, String message, Object... args) { + super(cause, message, args); + } +} diff --git a/api/src/main/java/com/datastrato/gravitino/exceptions/NoSuchGroupException.java b/api/src/main/java/com/datastrato/gravitino/exceptions/NoSuchGroupException.java new file mode 100644 index 00000000000..a9089233003 --- /dev/null +++ b/api/src/main/java/com/datastrato/gravitino/exceptions/NoSuchGroupException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ + +package com.datastrato.gravitino.exceptions; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; + +/** Exception thrown when a group with specified name is not existed. */ +public class NoSuchGroupException extends NotFoundException { + /** + * Constructs a new exception with the specified detail message. + * + * @param message the detail message. + * @param args the arguments to the message. + */ + @FormatMethod + public NoSuchGroupException(@FormatString String message, Object... args) { + super(message, args); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param cause the cause. + * @param message the detail message. + * @param args the arguments to the message. + */ + @FormatMethod + public NoSuchGroupException(Throwable cause, String message, Object... args) { + super(cause, message, args); + } +} diff --git a/common/src/main/java/com/datastrato/gravitino/dto/GroupDTO.java b/common/src/main/java/com/datastrato/gravitino/dto/GroupDTO.java new file mode 100644 index 00000000000..208015ee7f5 --- /dev/null +++ b/common/src/main/java/com/datastrato/gravitino/dto/GroupDTO.java @@ -0,0 +1,160 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.dto; + +import com.datastrato.gravitino.Audit; +import com.datastrato.gravitino.Group; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Preconditions; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +/** Represents a Group Data Transfer Object (DTO). */ +public class GroupDTO implements Group { + + @JsonProperty("name") + private String name; + + @Nullable + @JsonProperty("properties") + private Map properties; + + @JsonProperty("audit") + private AuditDTO audit; + + @JsonProperty("users") + private List users; + + /** Default constructor for Jackson deserialization. */ + protected GroupDTO() {} + + /** + * Creates a new instance of GroupDTO. + * + * @param name The name of the Group DTO. + * @param users The collection of users which belongs to the group. + * @param properties The properties of the Group DTO. + * @param audit The audit information of the Group DTO. + */ + protected GroupDTO( + String name, List users, Map properties, AuditDTO audit) { + this.name = name; + this.properties = properties; + this.audit = audit; + this.users = users; + } + + /** @return The users of the Group DTO. */ + @Override + public List users() { + return users; + } + + /** @return The name of the Group DTO. */ + @Override + public String name() { + return name; + } + + /** @return The properties of the Group DTO. */ + @Override + public Map properties() { + return properties; + } + + /** @return The audit information of the Group DTO. */ + @Override + public Audit auditInfo() { + return audit; + } + + /** + * Creates a new Builder for constructing a Group DTO. + * + * @return A new Builder instance. + */ + public static Builder builder() { + return new Builder(); + } + /** + * Builder class for constructing a GroupDTO instance. + * + * @param The type of the builder instance. + */ + public static class Builder { + /** The name of the group. */ + protected String name; + + /** The properties of the group. */ + protected Map properties; + + /** The audit information of the group. */ + protected AuditDTO audit; + + /** The users of the group. */ + protected List users; + + /** + * Sets the name of the group. + * + * @param name The name of the group. + * @return The builder instance. + */ + public S withName(String name) { + this.name = name; + return (S) this; + } + + /** + * Sets the properties of the group. + * + * @param properties The properties of the group. + * @return The builder instance. + */ + public S withProperties(Map properties) { + this.properties = properties; + return (S) this; + } + + /** + * Sets the audit information of the group. + * + * @param audit The audit information of the group. + * @return The builder instance. + */ + public S withAudit(AuditDTO audit) { + this.audit = audit; + return (S) this; + } + + /** + * Sets the users of the group. + * + * @param users The users of the group. + * @return The builder instance. + */ + public S withUsers(List users) { + this.users = users; + return (S) this; + } + + /** + * Builds an instance of GroupDTO using the builder's properties. + * + * @return An instance of GroupDTO. + * @throws IllegalArgumentException If the name or audit are not set. + */ + public GroupDTO build() { + Preconditions.checkArgument(StringUtils.isNotBlank(name), "name cannot be null or empty"); + Preconditions.checkArgument(audit != null, "audit cannot be null"); + Preconditions.checkArgument( + CollectionUtils.isNotEmpty(users), "users cannot be null or empty"); + return new GroupDTO(name, users, properties, audit); + } + } +} diff --git a/common/src/main/java/com/datastrato/gravitino/dto/requests/GroupCreateRequest.java b/common/src/main/java/com/datastrato/gravitino/dto/requests/GroupCreateRequest.java new file mode 100644 index 00000000000..388586dcc6a --- /dev/null +++ b/common/src/main/java/com/datastrato/gravitino/dto/requests/GroupCreateRequest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.dto.requests; + +import com.datastrato.gravitino.rest.RESTRequest; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Preconditions; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.jackson.Jacksonized; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +/** Request to create a group. */ +@Getter +@EqualsAndHashCode +@ToString +@Builder +@Jacksonized +public class GroupCreateRequest implements RESTRequest { + + @JsonProperty("name") + private final String name; + + @JsonProperty("users") + private final List users; + + @Nullable + @JsonProperty("properties") + private final Map properties; + + /** Default constructor for GroupCreateRequest. (Used for Jackson deserialization.) */ + public GroupCreateRequest() { + this(null, null, null); + } + + /** + * Creates a new GroupCreateRequest. + * + * @param name The name of the group. + * @param users The users of the group. + * @param properties The properties of the group. + */ + public GroupCreateRequest(String name, List users, Map properties) { + super(); + this.name = name; + this.properties = properties; + this.users = users; + } + + @Override + public void validate() throws IllegalArgumentException { + Preconditions.checkArgument( + StringUtils.isNotBlank(name), "\"name\" field is required and cannot be empty"); + Preconditions.checkArgument( + CollectionUtils.isEmpty(users), "\"users\" field is required and cannot be empty"); + } +} diff --git a/common/src/main/java/com/datastrato/gravitino/dto/requests/UserCreateRequest.java b/common/src/main/java/com/datastrato/gravitino/dto/requests/UserCreateRequest.java index d5c46fe0087..187087a1f1e 100644 --- a/common/src/main/java/com/datastrato/gravitino/dto/requests/UserCreateRequest.java +++ b/common/src/main/java/com/datastrato/gravitino/dto/requests/UserCreateRequest.java @@ -31,7 +31,7 @@ public class UserCreateRequest implements RESTRequest { @JsonProperty("properties") private final Map properties; - /** Default constructor for MetalakeCreateRequest. (Used for Jackson deserialization.) */ + /** Default constructor for UserCreateRequest. (Used for Jackson deserialization.) */ public UserCreateRequest() { this(null, null); } diff --git a/common/src/main/java/com/datastrato/gravitino/dto/responses/GroupResponse.java b/common/src/main/java/com/datastrato/gravitino/dto/responses/GroupResponse.java new file mode 100644 index 00000000000..901757bba0b --- /dev/null +++ b/common/src/main/java/com/datastrato/gravitino/dto/responses/GroupResponse.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.dto.responses; + +import com.datastrato.gravitino.dto.GroupDTO; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Preconditions; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +/** Represents a response for a group. */ +@Getter +@ToString +@EqualsAndHashCode(callSuper = true) +public class GroupResponse extends BaseResponse { + + @JsonProperty("group") + private final GroupDTO group; + + /** + * Constructor for GroupResponse. + * + * @param group The group data transfer object. + */ + public GroupResponse(GroupDTO group) { + super(0); + this.group = group; + } + + /** Default constructor for GroupResponse. (Used for Jackson deserialization.) */ + public GroupResponse() { + super(); + this.group = null; + } + + /** + * Validates the response data. + * + * @throws IllegalArgumentException if the name, users or audit is not set. + */ + @Override + public void validate() throws IllegalArgumentException { + super.validate(); + + Preconditions.checkArgument(group != null, "group must not be null"); + Preconditions.checkArgument( + StringUtils.isNotBlank(group.name()), "group 'name' must not be null and empty"); + Preconditions.checkArgument(group.auditInfo() != null, "group 'auditInfo' must not be null"); + Preconditions.checkArgument( + CollectionUtils.isNotEmpty(group.users()), "group 'users' must not be null and empty"); + } +} diff --git a/common/src/main/java/com/datastrato/gravitino/dto/util/DTOConverters.java b/common/src/main/java/com/datastrato/gravitino/dto/util/DTOConverters.java index 65d4a85ba75..6bb0b6a561e 100644 --- a/common/src/main/java/com/datastrato/gravitino/dto/util/DTOConverters.java +++ b/common/src/main/java/com/datastrato/gravitino/dto/util/DTOConverters.java @@ -8,10 +8,12 @@ import com.datastrato.gravitino.Audit; import com.datastrato.gravitino.Catalog; +import com.datastrato.gravitino.Group; import com.datastrato.gravitino.Metalake; import com.datastrato.gravitino.User; import com.datastrato.gravitino.dto.AuditDTO; import com.datastrato.gravitino.dto.CatalogDTO; +import com.datastrato.gravitino.dto.GroupDTO; import com.datastrato.gravitino.dto.MetalakeDTO; import com.datastrato.gravitino.dto.UserDTO; import com.datastrato.gravitino.dto.file.FilesetDTO; @@ -345,6 +347,24 @@ public static UserDTO toDTO(User user) { .build(); } + /** + * Converts a group implementation to a GroupDTO. + * + * @param group The group implementation. + * @return The group DTO. + */ + public static GroupDTO toDTO(Group group) { + if (group instanceof GroupDTO) { + return (GroupDTO) group; + } + return GroupDTO.builder() + .withName(group.name()) + .withAudit(toDTO(group.auditInfo())) + .withProperties(group.properties()) + .withUsers(group.users()) + .build(); + } + /** * Converts a Expression to an FunctionArg DTO. * diff --git a/common/src/test/java/com/datastrato/gravitino/dto/responses/TestResponses.java b/common/src/test/java/com/datastrato/gravitino/dto/responses/TestResponses.java index ced42ff2372..1d3198033cf 100644 --- a/common/src/test/java/com/datastrato/gravitino/dto/responses/TestResponses.java +++ b/common/src/test/java/com/datastrato/gravitino/dto/responses/TestResponses.java @@ -13,6 +13,7 @@ import com.datastrato.gravitino.NameIdentifier; import com.datastrato.gravitino.dto.AuditDTO; import com.datastrato.gravitino.dto.CatalogDTO; +import com.datastrato.gravitino.dto.GroupDTO; import com.datastrato.gravitino.dto.MetalakeDTO; import com.datastrato.gravitino.dto.UserDTO; import com.datastrato.gravitino.dto.rel.ColumnDTO; @@ -20,6 +21,7 @@ import com.datastrato.gravitino.dto.rel.TableDTO; import com.datastrato.gravitino.dto.rel.partitioning.Partitioning; import com.datastrato.gravitino.rel.types.Types; +import com.google.common.collect.Lists; import java.time.Instant; import org.junit.jupiter.api.Test; @@ -228,7 +230,7 @@ void testOAuthErrorException() throws IllegalArgumentException { @Test void testUserResponse() throws IllegalArgumentException { AuditDTO audit = - new AuditDTO.Builder().withCreator("creator").withCreateTime(Instant.now()).build(); + AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build(); UserDTO user = UserDTO.builder().withName("user1").withAudit(audit).build(); UserResponse response = new UserResponse(user); response.validate(); // No exception thrown @@ -239,4 +241,24 @@ void testUserResponseException() throws IllegalArgumentException { UserResponse user = new UserResponse(); assertThrows(IllegalArgumentException.class, () -> user.validate()); } + + @Test + void testGroupResponse() throws IllegalArgumentException { + AuditDTO audit = + AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build(); + GroupDTO group = + GroupDTO.builder() + .withName("group") + .withUsers(Lists.newArrayList("user")) + .withAudit(audit) + .build(); + GroupResponse response = new GroupResponse(group); + response.validate(); // No exception thrown + } + + @Test + void testGroupResponseException() throws IllegalArgumentException { + GroupResponse group = new GroupResponse(); + assertThrows(IllegalArgumentException.class, () -> group.validate()); + } } diff --git a/core/src/main/java/com/datastrato/gravitino/Entity.java b/core/src/main/java/com/datastrato/gravitino/Entity.java index 95492996ee0..754c3641229 100644 --- a/core/src/main/java/com/datastrato/gravitino/Entity.java +++ b/core/src/main/java/com/datastrato/gravitino/Entity.java @@ -22,6 +22,7 @@ enum EntityType { FILESET("fi", 5), TOPIC("to", 6), USER("us", 7), + GROUP("gr", 8), AUDIT("au", 65534); diff --git a/core/src/main/java/com/datastrato/gravitino/meta/BaseMetalake.java b/core/src/main/java/com/datastrato/gravitino/meta/BaseMetalake.java index f4c22470057..080b6e103fa 100644 --- a/core/src/main/java/com/datastrato/gravitino/meta/BaseMetalake.java +++ b/core/src/main/java/com/datastrato/gravitino/meta/BaseMetalake.java @@ -130,6 +130,15 @@ public Map properties() { return StringIdentifier.newPropertiesWithoutId(properties); } + /** + * Creates a new Builder for constructing a BaseMetalake. + * + * @return A new Builder instance. + */ + public static Builder builder() { + return new Builder(); + } + /** Builder class for creating instances of {@link BaseMetalake}. */ public static class Builder { private final BaseMetalake metalake; diff --git a/core/src/main/java/com/datastrato/gravitino/meta/MetalakeGroup.java b/core/src/main/java/com/datastrato/gravitino/meta/MetalakeGroup.java new file mode 100644 index 00000000000..df25a1eadd6 --- /dev/null +++ b/core/src/main/java/com/datastrato/gravitino/meta/MetalakeGroup.java @@ -0,0 +1,226 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.meta; + +import com.datastrato.gravitino.Auditable; +import com.datastrato.gravitino.Entity; +import com.datastrato.gravitino.Field; +import com.datastrato.gravitino.Group; +import com.datastrato.gravitino.HasIdentifier; +import com.datastrato.gravitino.NameIdentifier; +import com.google.common.collect.Maps; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class MetalakeGroup implements Group, Entity, Auditable, HasIdentifier { + + public static final Field ID = + Field.required("id", Long.class, " The unique id of the group entity."); + public static final Field NAME = + Field.required("name", String.class, "The name of the group entity."); + public static final Field PROPERTIES = + Field.optional("properties", Map.class, "The properties of the group entity."); + + public static final Field USERS = + Field.required("users", List.class, "The users of the group entity."); + public static final Field AUDIT_INFO = + Field.required("audit_info", AuditInfo.class, "The audit details of the group entity."); + + private Long id; + private String name; + private Map properties; + private AuditInfo auditInfo; + private String metalake; + private List users; + + private MetalakeGroup() {} + + /** + * Returns a map of fields and their corresponding values for this group entity. + * + * @return An unmodifiable map of the fields and values. + */ + @Override + public Map fields() { + Map fields = Maps.newHashMap(); + fields.put(ID, id); + fields.put(NAME, name); + fields.put(AUDIT_INFO, auditInfo); + fields.put(USERS, users); + fields.put(PROPERTIES, properties); + + return Collections.unmodifiableMap(fields); + } + + /** + * Returns the name of the group. + * + * @return The name of the group. + */ + @Override + public String name() { + return name; + } + + /** + * Returns the unique id of the group. + * + * @return The unique id of the group. + */ + @Override + public Long id() { + return id; + } + + /** + * Returns the type of the entity. + * + * @return The type of the entity. + */ + @Override + public EntityType type() { + return EntityType.GROUP; + } + + /** + * Returns the audit details of the group. + * + * @return The audit details of the group. + */ + public AuditInfo auditInfo() { + return auditInfo; + } + + /** + * Returns the properties of the group entity. + * + * @return The properties of the group entity. + */ + public Map properties() { + return properties; + } + + /** + * Returns the users of the group entity. + * + * @return The users of the group entity. + */ + public List users() { + return users; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MetalakeGroup)) return false; + + MetalakeGroup that = (MetalakeGroup) o; + return Objects.equals(id, that.id) + && Objects.equals(name, that.name) + && Objects.equals(auditInfo, that.auditInfo) + && Objects.equals(properties, that.properties) + && Objects.equals(users, that.users); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, properties, auditInfo, users); + } + + @Override + public NameIdentifier nameIdentifier() { + return NameIdentifier.of(metalake, name); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final MetalakeGroup metalakeGroup; + + private Builder() { + this.metalakeGroup = new MetalakeGroup(); + } + + /** + * Sets the unique id of the group entity. + * + * @param id The unique id of the group entity. + * @return The builder instance. + */ + public Builder withId(Long id) { + metalakeGroup.id = id; + return this; + } + + /** + * Sets the name of the group entity. + * + * @param name The name of the group entity. + * @return The builder instance. + */ + public Builder withName(String name) { + metalakeGroup.name = name; + return this; + } + + /** + * Sets the properties of the group entity. + * + * @param properties The properties of the group entity. + * @return The builder instance. + */ + public Builder withProperties(Map properties) { + metalakeGroup.properties = properties; + return this; + } + + /** + * Sets the audit details of the group entity. + * + * @param auditInfo The audit details of the group entity. + * @return The builder instance. + */ + public Builder withAuditInfo(AuditInfo auditInfo) { + metalakeGroup.auditInfo = auditInfo; + return this; + } + + /** + * Sets the metalake of the group entity. + * + * @param metalake The metalake of the group entity. + * @return The builder instance. + */ + public Builder withMetalake(String metalake) { + metalakeGroup.metalake = metalake; + return this; + } + + /** + * Sets the users of the group entity. + * + * @param users The users of the group entity. + * @return The builder instance. + */ + public Builder withUsers(List users) { + metalakeGroup.users = users; + return this; + } + + /** + * Builds the group entity. + * + * @return The built group entity. + */ + public MetalakeGroup build() { + metalakeGroup.validate(); + return metalakeGroup; + } + } +} diff --git a/core/src/main/java/com/datastrato/gravitino/proto/GroupEntitySerDe.java b/core/src/main/java/com/datastrato/gravitino/proto/GroupEntitySerDe.java new file mode 100644 index 00000000000..4e14bfc8b45 --- /dev/null +++ b/core/src/main/java/com/datastrato/gravitino/proto/GroupEntitySerDe.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.proto; + +import com.datastrato.gravitino.meta.MetalakeGroup; + +public class GroupEntitySerDe + implements ProtoSerDe { + + /** + * Serializes the provided entity into its corresponding Protocol Buffer message representation. + * + * @param metalakeGroup The entity to be serialized. + * @return The Protocol Buffer message representing the serialized entity. + */ + @Override + public Group serialize(MetalakeGroup metalakeGroup) { + Group.Builder builder = + Group.newBuilder() + .addAllUser(metalakeGroup.users()) + .setId(metalakeGroup.id()) + .setName(metalakeGroup.name()) + .setAuditInfo(new AuditInfoSerDe().serialize(metalakeGroup.auditInfo())); + + if (metalakeGroup.properties() != null && !metalakeGroup.properties().isEmpty()) { + builder.putAllProperties(metalakeGroup.properties()); + } + + return builder.build(); + } + + /** + * Deserializes the provided Protocol Buffer message into its corresponding entity representation. + * + * @param p The Protocol Buffer message to be deserialized. + * @return The entity representing the deserialized Protocol Buffer message. + */ + @Override + public MetalakeGroup deserialize(Group group) { + MetalakeGroup.Builder builder = + MetalakeGroup.builder() + .withId(group.getId()) + .withName(group.getName()) + .withUsers(group.getUserList()) + .withAuditInfo(new AuditInfoSerDe().deserialize(group.getAuditInfo())); + if (group.getPropertiesCount() > 0) { + builder.withProperties(group.getPropertiesMap()); + } + return builder.build(); + } +} diff --git a/core/src/main/java/com/datastrato/gravitino/proto/MetalakeUserEntitySerDe.java b/core/src/main/java/com/datastrato/gravitino/proto/MetalakeUserEntitySerDe.java index 08ce08190c9..9e2e101edb0 100644 --- a/core/src/main/java/com/datastrato/gravitino/proto/MetalakeUserEntitySerDe.java +++ b/core/src/main/java/com/datastrato/gravitino/proto/MetalakeUserEntitySerDe.java @@ -11,7 +11,7 @@ public class MetalakeUserEntitySerDe @Override public com.datastrato.gravitino.proto.User serialize(MetalakeUser metalakeUser) { - com.datastrato.gravitino.proto.User.Builder builder = + User.Builder builder = com.datastrato.gravitino.proto.User.newBuilder() .setId(metalakeUser.id()) .setName(metalakeUser.name()) diff --git a/core/src/main/java/com/datastrato/gravitino/proto/ProtoEntitySerDe.java b/core/src/main/java/com/datastrato/gravitino/proto/ProtoEntitySerDe.java index b875f669fbd..1d1f5e8ef7f 100644 --- a/core/src/main/java/com/datastrato/gravitino/proto/ProtoEntitySerDe.java +++ b/core/src/main/java/com/datastrato/gravitino/proto/ProtoEntitySerDe.java @@ -43,6 +43,9 @@ public class ProtoEntitySerDe implements EntitySerDe { .put( "com.datastrato.gravitino.meta.MetalakeUser", "com.datastrato.gravitino.proto.MetalakeUserEntitySerDe") + .put( + "com.datastrato.gravitino.meta.MetalakeGroup", + "com.datastrato.gravitino.proto.GroupEntitySerDe") .build(); private static final Map ENTITY_TO_PROTO = @@ -62,7 +65,9 @@ public class ProtoEntitySerDe implements EntitySerDe { "com.datastrato.gravitino.meta.TopicEntity", "com.datastrato.gravitino.proto.Topic", "com.datastrato.gravitino.meta.MetalakeUser", - "com.datastrato.gravitino.proto.User"); + "com.datastrato.gravitino.proto.User", + "com.datastrato.gravitino.meta.MetalakeGroup", + "com.datastrato.gravitino.proto.Group"); private final Map, ProtoSerDe> entityToSerDe; diff --git a/core/src/main/java/com/datastrato/gravitino/storage/kv/BinaryEntityKeyEncoder.java b/core/src/main/java/com/datastrato/gravitino/storage/kv/BinaryEntityKeyEncoder.java index 9db2fa24a7b..e497cdd5802 100644 --- a/core/src/main/java/com/datastrato/gravitino/storage/kv/BinaryEntityKeyEncoder.java +++ b/core/src/main/java/com/datastrato/gravitino/storage/kv/BinaryEntityKeyEncoder.java @@ -6,6 +6,7 @@ import static com.datastrato.gravitino.Entity.EntityType.CATALOG; import static com.datastrato.gravitino.Entity.EntityType.FILESET; +import static com.datastrato.gravitino.Entity.EntityType.GROUP; import static com.datastrato.gravitino.Entity.EntityType.METALAKE; import static com.datastrato.gravitino.Entity.EntityType.SCHEMA; import static com.datastrato.gravitino.Entity.EntityType.TABLE; @@ -78,7 +79,9 @@ public class BinaryEntityKeyEncoder implements EntityKeyEncoder { FILESET, new String[] {FILESET.getShortName() + "/", "/", "/", "/"}, USER, - new String[] {USER.getShortName() + "/", "/"}); + new String[] {USER.getShortName() + "/", "/"}, + GROUP, + new String[] {GROUP.getShortName() + "/", "/"}); @VisibleForTesting final NameMappingService nameMappingService; diff --git a/core/src/main/java/com/datastrato/gravitino/storage/kv/KvEntityStore.java b/core/src/main/java/com/datastrato/gravitino/storage/kv/KvEntityStore.java index 75e1003155a..a32fc04228f 100644 --- a/core/src/main/java/com/datastrato/gravitino/storage/kv/KvEntityStore.java +++ b/core/src/main/java/com/datastrato/gravitino/storage/kv/KvEntityStore.java @@ -8,6 +8,7 @@ import static com.datastrato.gravitino.Configs.ENTITY_KV_STORE; import static com.datastrato.gravitino.Entity.EntityType.CATALOG; import static com.datastrato.gravitino.Entity.EntityType.FILESET; +import static com.datastrato.gravitino.Entity.EntityType.GROUP; import static com.datastrato.gravitino.Entity.EntityType.METALAKE; import static com.datastrato.gravitino.Entity.EntityType.SCHEMA; import static com.datastrato.gravitino.Entity.EntityType.TABLE; @@ -334,18 +335,22 @@ private List getSubEntitiesPrefix(NameIdentifier ident, EntityType type) return prefixes; } - void deleteUserEntitiesIfNecessary(NameIdentifier ident, EntityType type) throws IOException { + void deleteAccessControlEntitiesIfNecessary(NameIdentifier ident, EntityType type) + throws IOException { if (type != METALAKE) { return; } byte[] encode = entityKeyEncoder.encode(ident, type, true); - byte[] prefix = replacePrefixTypeInfo(encode, USER.getShortName()); - transactionalKvBackend.deleteRange( - new KvRange.KvRangeBuilder() - .start(prefix) - .startInclusive(true) - .end(Bytes.increment(Bytes.wrap(prefix)).get()) - .build()); + List entityPrefixes = Lists.newArrayList(USER.getShortName(), GROUP.getShortName()); + for (String prefix : entityPrefixes) { + byte[] encodedPrefix = replacePrefixTypeInfo(encode, prefix); + transactionalKvBackend.deleteRange( + new KvRange.KvRangeBuilder() + .start(encodedPrefix) + .startInclusive(true) + .end(Bytes.increment(Bytes.wrap(encodedPrefix)).get()) + .build()); + } } private byte[] replacePrefixTypeInfo(byte[] encode, String subTypePrefix) { @@ -371,7 +376,7 @@ public boolean delete(NameIdentifier ident, EntityType entityType, boolean casca List subEntityPrefix = getSubEntitiesPrefix(ident, entityType); if (subEntityPrefix.isEmpty()) { // has no sub-entities - deleteUserEntitiesIfNecessary(ident, entityType); + deleteAccessControlEntitiesIfNecessary(ident, entityType); return transactionalKvBackend.delete(dataKey); } @@ -407,7 +412,7 @@ public boolean delete(NameIdentifier ident, EntityType entityType, boolean casca .build()); } - deleteUserEntitiesIfNecessary(ident, entityType); + deleteAccessControlEntitiesIfNecessary(ident, entityType); return transactionalKvBackend.delete(dataKey); }); } diff --git a/core/src/main/java/com/datastrato/gravitino/tenant/AccessControlManager.java b/core/src/main/java/com/datastrato/gravitino/tenant/AccessControlManager.java index 1e2399a3dfd..9abc0a0d75e 100644 --- a/core/src/main/java/com/datastrato/gravitino/tenant/AccessControlManager.java +++ b/core/src/main/java/com/datastrato/gravitino/tenant/AccessControlManager.java @@ -7,26 +7,33 @@ import com.datastrato.gravitino.Entity; import com.datastrato.gravitino.EntityAlreadyExistsException; import com.datastrato.gravitino.EntityStore; +import com.datastrato.gravitino.Group; import com.datastrato.gravitino.NameIdentifier; import com.datastrato.gravitino.User; +import com.datastrato.gravitino.exceptions.GroupAlreadyExistsException; import com.datastrato.gravitino.exceptions.NoSuchEntityException; +import com.datastrato.gravitino.exceptions.NoSuchGroupException; import com.datastrato.gravitino.exceptions.NoSuchUserException; import com.datastrato.gravitino.exceptions.UserAlreadyExistsException; import com.datastrato.gravitino.meta.AuditInfo; +import com.datastrato.gravitino.meta.MetalakeGroup; import com.datastrato.gravitino.meta.MetalakeUser; import com.datastrato.gravitino.storage.IdGenerator; import com.datastrato.gravitino.utils.PrincipalUtils; import java.io.IOException; import java.time.Instant; +import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /* AccessControlManager is used for manage users, roles, grant information, this class is * an important class for tenant management. */ -public class AccessControlManager implements SupportsUserManagement { +public class AccessControlManager implements SupportsUserManagement, SupportsGroupManagement { private static final String USER_DOES_NOT_EXIST_MSG = "User %s does not exist in th metalake %s"; + private static final String GROUP_DOES_NOT_EXIST_MSG = + "Group %s does not exist in th metalake %s"; private static final Logger LOG = LoggerFactory.getLogger(AccessControlManager.class); @@ -131,4 +138,94 @@ public User loadUser(String metalake, String userName) throws NoSuchUserExceptio throw new RuntimeException(ioe); } } + + /** + * Creates a new Group. + * + * @param metalake The Metalake of the Group. + * @param group The name of the Group. + * @param users The users of the Group. + * @param properties Additional properties for the Metalake. + * @return The created Group instance. + * @throws GroupAlreadyExistsException If a Group with the same identifier already exists. + * @throws RuntimeException If creating the Group encounters storage issues. + */ + @Override + public Group createGroup( + String metalake, String group, List users, Map properties) + throws GroupAlreadyExistsException { + MetalakeGroup metalakeGroup = + MetalakeGroup.builder() + .withId(idGenerator.nextId()) + .withName(group) + .withMetalake(metalake) + .withProperties(properties) + .withUsers(users) + .withAuditInfo( + AuditInfo.builder() + .withCreator(PrincipalUtils.getCurrentPrincipal().getName()) + .withCreateTime(Instant.now()) + .build()) + .build(); + try { + store.put(metalakeGroup, false /* overwritten */); + return metalakeGroup; + } catch (EntityAlreadyExistsException e) { + LOG.warn("Group {} in the metalake {} already exists", group, metalake, e); + throw new GroupAlreadyExistsException( + "Group %s in the metalake %s already exists", group, metalake); + } catch (IOException ioe) { + LOG.error( + "Creating group {} failed in the metalake {} due to storage issues", + group, + metalake, + ioe); + throw new RuntimeException(ioe); + } + } + + /** + * Deletes a Group. + * + * @param metalake The Metalake of the Group. + * @param group THe name of the Group. + * @return `true` if the Group was successfully deleted, `false` otherwise. + * @throws RuntimeException If deleting the Group encounters storage issues. + */ + @Override + public boolean dropGroup(String metalake, String group) { + try { + return store.delete(NameIdentifier.of(metalake, group), Entity.EntityType.GROUP); + } catch (IOException ioe) { + LOG.error( + "Deleting group {} in the metalake {} failed due to storage issues", + group, + metalake, + ioe); + throw new RuntimeException(ioe); + } + } + + /** + * Loads a Group. + * + * @param metalake The Metalake of the Group. + * @param group THe name of the Group. + * @return The loaded Group instance. + * @throws NoSuchGroupException If the Group with the given identifier does not exist. + * @throws RuntimeException If loading the Group encounters storage issues. + */ + @Override + public Group loadGroup(String metalake, String group) { + try { + return store.get( + NameIdentifier.of(metalake, group), Entity.EntityType.GROUP, MetalakeGroup.class); + } catch (NoSuchEntityException e) { + LOG.warn("Group {} does not exist in the metalake {}", group, metalake, e); + throw new NoSuchGroupException(GROUP_DOES_NOT_EXIST_MSG, group, metalake); + } catch (IOException ioe) { + LOG.error("Loading group {} failed due to storage issues", group, ioe); + throw new RuntimeException(ioe); + } + } } diff --git a/core/src/main/java/com/datastrato/gravitino/tenant/SupportsGroupManagement.java b/core/src/main/java/com/datastrato/gravitino/tenant/SupportsGroupManagement.java new file mode 100644 index 00000000000..f7c3faca96f --- /dev/null +++ b/core/src/main/java/com/datastrato/gravitino/tenant/SupportsGroupManagement.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.tenant; + +import com.datastrato.gravitino.Group; +import java.util.List; +import java.util.Map; + +public interface SupportsGroupManagement { + + /** + * Creates a new Group. + * + * @param metalake The Metalake of the Group. + * @param group THe name of the Group. + * @param properties Additional properties for the Metalake. + * @return The created Group instance. + * @throws GroupAlreadyExistsException If a Group with the same identifier already exists. + * @throws RuntimeException If creating the Group encounters storage issues. + */ + Group createGroup( + String metalake, String group, List users, Map properties); + + /** + * Deletes a Group. + * + * @param metalake The Metalake of the Group. + * @param group THe name of the Group. + * @return `true` if the Group was successfully deleted, `false` otherwise. + * @throws RuntimeException If deleting the Group encounters storage issues. + */ + boolean dropGroup(String metalake, String group); + + /** + * Loads a Group. + * + * @param metalake The Metalake of the Group. + * @param group THe name of the Group. + * @return The loaded Group instance. + * @throws NoSuchGroupException If the Group with the given identifier does not exist. + * @throws RuntimeException If loading the Group encounters storage issues. + */ + Group loadGroup(String metalake, String group); +} diff --git a/core/src/test/java/com/datastrato/gravitino/meta/TestEntity.java b/core/src/test/java/com/datastrato/gravitino/meta/TestEntity.java index 1e13b057e14..bed34fddec8 100644 --- a/core/src/test/java/com/datastrato/gravitino/meta/TestEntity.java +++ b/core/src/test/java/com/datastrato/gravitino/meta/TestEntity.java @@ -8,7 +8,9 @@ import com.datastrato.gravitino.Field; import com.datastrato.gravitino.file.Fileset; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; import java.time.Instant; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -51,6 +53,11 @@ public class TestEntity { private final Long userId = 1L; private final String userName = "testUser"; + // Group test data + private final Long groupId = 1L; + private final String groupName = "testGroup"; + private final List groupUsers = Lists.newArrayList("user"); + @Test public void testMetalake() { BaseMetalake metalake = @@ -225,4 +232,31 @@ public void testUser() { MetalakeUser.builder().withId(userId).withName(userName).withAuditInfo(auditInfo).build(); Assertions.assertNull(testMetalakeUserWithoutFields.properties()); } + + @Test + public void testGroup() { + MetalakeGroup group = + MetalakeGroup.builder() + .withId(groupId) + .withName(groupName) + .withAuditInfo(auditInfo) + .withProperties(map) + .withUsers(groupUsers) + .build(); + Map fields = group.fields(); + Assertions.assertEquals(groupId, fields.get(MetalakeGroup.ID)); + Assertions.assertEquals(groupName, fields.get(MetalakeGroup.NAME)); + Assertions.assertEquals(auditInfo, fields.get(MetalakeGroup.AUDIT_INFO)); + Assertions.assertEquals(map, fields.get(MetalakeGroup.PROPERTIES)); + Assertions.assertEquals(groupUsers, fields.get(MetalakeGroup.USERS)); + + MetalakeGroup groupWithoutFields = + MetalakeGroup.builder() + .withId(userId) + .withName(userName) + .withUsers(groupUsers) + .withAuditInfo(auditInfo) + .build(); + Assertions.assertNull(groupWithoutFields.properties()); + } } diff --git a/core/src/test/java/com/datastrato/gravitino/proto/TestEntityProtoSerDe.java b/core/src/test/java/com/datastrato/gravitino/proto/TestEntityProtoSerDe.java index a15006ab667..313e66bcd0e 100644 --- a/core/src/test/java/com/datastrato/gravitino/proto/TestEntityProtoSerDe.java +++ b/core/src/test/java/com/datastrato/gravitino/proto/TestEntityProtoSerDe.java @@ -6,11 +6,14 @@ import com.datastrato.gravitino.EntitySerDe; import com.datastrato.gravitino.EntitySerDeFactory; +import com.datastrato.gravitino.meta.MetalakeGroup; import com.datastrato.gravitino.meta.MetalakeUser; import com.datastrato.gravitino.meta.SchemaVersion; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; import java.io.IOException; import java.time.Instant; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -297,5 +300,34 @@ public void testEntitiesSerDe() throws IOException { metalakeUserFromBytes = protoEntitySerDe.deserialize(userBytes, MetalakeUser.class); Assertions.assertEquals(metalakeUserWithoutFields, metalakeUserFromBytes); Assertions.assertNull(metalakeUserFromBytes.properties()); + + // Test GroupEntity + Long groupId = 1L; + String groupName = "group"; + List users = Lists.newArrayList("user"); + + MetalakeGroup group = + MetalakeGroup.builder() + .withId(groupId) + .withName(groupName) + .withAuditInfo(auditInfo) + .withProperties(props) + .withUsers(users) + .build(); + byte[] groupBytes = protoEntitySerDe.serialize(group); + MetalakeGroup groupFromBytes = protoEntitySerDe.deserialize(groupBytes, MetalakeGroup.class); + Assertions.assertEquals(group, groupFromBytes); + + MetalakeGroup groupWithoutFields = + MetalakeGroup.builder() + .withId(groupId) + .withUsers(users) + .withName(groupName) + .withAuditInfo(auditInfo) + .build(); + groupBytes = protoEntitySerDe.serialize(groupWithoutFields); + groupFromBytes = protoEntitySerDe.deserialize(groupBytes, MetalakeGroup.class); + Assertions.assertEquals(groupWithoutFields, groupFromBytes); + Assertions.assertNull(groupFromBytes.properties()); } } diff --git a/core/src/test/java/com/datastrato/gravitino/storage/kv/TestKvEntityStorage.java b/core/src/test/java/com/datastrato/gravitino/storage/kv/TestKvEntityStorage.java index 7eb623a7a18..31efee38e74 100644 --- a/core/src/test/java/com/datastrato/gravitino/storage/kv/TestKvEntityStorage.java +++ b/core/src/test/java/com/datastrato/gravitino/storage/kv/TestKvEntityStorage.java @@ -31,11 +31,13 @@ import com.datastrato.gravitino.meta.BaseMetalake; import com.datastrato.gravitino.meta.CatalogEntity; import com.datastrato.gravitino.meta.FilesetEntity; +import com.datastrato.gravitino.meta.MetalakeGroup; import com.datastrato.gravitino.meta.MetalakeUser; import com.datastrato.gravitino.meta.SchemaEntity; import com.datastrato.gravitino.meta.SchemaVersion; import com.datastrato.gravitino.meta.TableEntity; import com.datastrato.gravitino.storage.StorageLayoutVersion; +import com.google.common.collect.Lists; import com.google.common.util.concurrent.ThreadFactoryBuilder; import java.io.File; import java.io.IOException; @@ -132,6 +134,16 @@ public static FilesetEntity createFilesetEntity( .build(); } + public static MetalakeGroup createGroup(String metalake, String name, AuditInfo auditInfo) { + return MetalakeGroup.builder() + .withId(1L) + .withName(name) + .withUsers(Lists.newArrayList("user")) + .withMetalake(metalake) + .withAuditInfo(auditInfo) + .build(); + } + @Test void testRestart() throws IOException { Config config = Mockito.mock(Config.class); @@ -619,7 +631,7 @@ private void validateDeletedTable(EntityStore store) throws IOException { } @Test - public void testUserEntityDelete() throws IOException { + public void testAccessControlEntityDelete() throws IOException { Config config = Mockito.mock(Config.class); Mockito.when(config.get(ENTITY_STORE)).thenReturn("kv"); Mockito.when(config.get(ENTITY_KV_STORE)).thenReturn(DEFAULT_ENTITY_KV_STORE); @@ -644,8 +656,11 @@ public void testUserEntityDelete() throws IOException { store.put(oneUser); MetalakeUser anotherUser = createUser("metalake", "anotherUser", auditInfo); store.put(anotherUser); + MetalakeGroup oneGroup = createGroup("metalake", "group", auditInfo); + store.put(oneGroup); Assertions.assertTrue(store.exists(oneUser.nameIdentifier(), EntityType.USER)); Assertions.assertTrue(store.exists(anotherUser.nameIdentifier(), EntityType.USER)); + Assertions.assertTrue(store.exists(oneGroup.nameIdentifier(), EntityType.GROUP)); store.delete(metalake.nameIdentifier(), EntityType.METALAKE); Assertions.assertFalse(store.exists(oneUser.nameIdentifier(), EntityType.USER)); Assertions.assertFalse(store.exists(anotherUser.nameIdentifier(), EntityType.USER)); diff --git a/core/src/test/java/com/datastrato/gravitino/tenant/TestAccessControlManager.java b/core/src/test/java/com/datastrato/gravitino/tenant/TestAccessControlManager.java index 0f9935ae2eb..fa4ae75102e 100644 --- a/core/src/test/java/com/datastrato/gravitino/tenant/TestAccessControlManager.java +++ b/core/src/test/java/com/datastrato/gravitino/tenant/TestAccessControlManager.java @@ -6,9 +6,12 @@ import com.datastrato.gravitino.Config; import com.datastrato.gravitino.EntityStore; +import com.datastrato.gravitino.Group; import com.datastrato.gravitino.StringIdentifier; import com.datastrato.gravitino.TestEntityStore; import com.datastrato.gravitino.User; +import com.datastrato.gravitino.exceptions.GroupAlreadyExistsException; +import com.datastrato.gravitino.exceptions.NoSuchGroupException; import com.datastrato.gravitino.exceptions.NoSuchUserException; import com.datastrato.gravitino.exceptions.UserAlreadyExistsException; import com.datastrato.gravitino.meta.AuditInfo; @@ -16,6 +19,7 @@ import com.datastrato.gravitino.meta.SchemaVersion; import com.datastrato.gravitino.storage.RandomIdGenerator; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; import java.io.IOException; import java.time.Instant; import java.util.Map; @@ -35,7 +39,7 @@ public class TestAccessControlManager { private static String metalake = "metalake"; private static BaseMetalake metalakeEntity = - new BaseMetalake.Builder() + BaseMetalake.builder() .withId(1L) .withName(metalake) .withAuditInfo( @@ -111,6 +115,60 @@ public void testDropUser() { Assertions.assertFalse(dropped1); } + @Test + public void testCreateGroup() { + Map props = ImmutableMap.of("key1", "value1"); + + Group group = + accessControlManager.createGroup("metalake", "create", Lists.newArrayList("user1"), props); + Assertions.assertEquals("create", group.name()); + testProperties(props, group.properties()); + Assertions.assertTrue(group.users().contains("user1")); + Assertions.assertEquals(1, group.users().size()); + + // Test with GroupAlreadyExistsException + Assertions.assertThrows( + GroupAlreadyExistsException.class, + () -> + accessControlManager.createGroup( + "metalake", "create", Lists.newArrayList("user1"), props)); + } + + @Test + public void testLoadGroup() { + Map props = ImmutableMap.of("k1", "v1"); + + accessControlManager.createGroup("metalake", "loadGroup", Lists.newArrayList("user1"), props); + + Group group = accessControlManager.loadGroup("metalake", "loadGroup"); + Assertions.assertEquals("loadGroup", group.name()); + testProperties(props, group.properties()); + Assertions.assertTrue(group.users().contains("user1")); + Assertions.assertEquals(1, group.users().size()); + + // Test load non-existed group + Throwable exception = + Assertions.assertThrows( + NoSuchGroupException.class, + () -> accessControlManager.loadGroup("metalake", "not-exist")); + Assertions.assertTrue(exception.getMessage().contains("Group not-exist does not exist")); + } + + @Test + public void testDropGroup() { + Map props = ImmutableMap.of("k1", "v1"); + + accessControlManager.createGroup("metalake", "testDrop", Lists.newArrayList("user1"), props); + + // Test drop group + boolean dropped = accessControlManager.dropGroup("metalake", "testDrop"); + Assertions.assertTrue(dropped); + + // Test drop non-existed group + boolean dropped1 = accessControlManager.dropGroup("metalake", "no-exist"); + Assertions.assertFalse(dropped1); + } + private void testProperties(Map expectedProps, Map testProps) { expectedProps.forEach( (k, v) -> { diff --git a/meta/src/main/proto/gravitino_meta.proto b/meta/src/main/proto/gravitino_meta.proto index 3522ae075f9..6ba6f191acd 100644 --- a/meta/src/main/proto/gravitino_meta.proto +++ b/meta/src/main/proto/gravitino_meta.proto @@ -108,3 +108,11 @@ message User { map properties = 3; AuditInfo audit_info = 4; } + +message Group { + uint64 id = 1; + string name = 2; + repeated string user = 3; + map properties = 4; + AuditInfo audit_info = 5; +} diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java b/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java index b28dfbe7b5f..bfe6a8253c6 100644 --- a/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java +++ b/server/src/main/java/com/datastrato/gravitino/server/web/rest/ExceptionHandlers.java @@ -6,6 +6,7 @@ import com.datastrato.gravitino.exceptions.CatalogAlreadyExistsException; import com.datastrato.gravitino.exceptions.FilesetAlreadyExistsException; +import com.datastrato.gravitino.exceptions.GroupAlreadyExistsException; import com.datastrato.gravitino.exceptions.MetalakeAlreadyExistsException; import com.datastrato.gravitino.exceptions.NoSuchMetalakeException; import com.datastrato.gravitino.exceptions.NonEmptySchemaException; @@ -61,6 +62,11 @@ public static Response handleUserException( return UserExceptionHandler.INSTANCE.handle(op, user, metalake, e); } + public static Response handleGroupException( + OperationType op, String group, String metalake, Exception e) { + return GroupExceptionHandler.INSTANCE.handle(op, group, metalake, e); + } + private static class PartitionExceptionHandler extends BaseExceptionHandler { private static final ExceptionHandler INSTANCE = new PartitionExceptionHandler(); @@ -265,10 +271,10 @@ private static class UserExceptionHandler extends BaseExceptionHandler { private static final ExceptionHandler INSTANCE = new UserExceptionHandler(); private static String getUserErrorMsg( - String fileset, String operation, String metalake, String reason) { + String user, String operation, String metalake, String reason) { return String.format( "Failed to operate user %s operation [%s] under metalake [%s], reason [%s]", - fileset, operation, metalake, reason); + user, operation, metalake, reason); } @Override @@ -292,6 +298,38 @@ public Response handle(OperationType op, String user, String metalake, Exception } } + private static class GroupExceptionHandler extends BaseExceptionHandler { + + private static final ExceptionHandler INSTANCE = new GroupExceptionHandler(); + + private static String getGroupErrorMsg( + String group, String operation, String metalake, String reason) { + return String.format( + "Failed to operate group %s operation [%s] under metalake [%s], reason [%s]", + group, operation, metalake, reason); + } + + @Override + public Response handle(OperationType op, String group, String metalake, Exception e) { + String formatted = StringUtil.isBlank(group) ? "" : " [" + group + "]"; + String errorMsg = getGroupErrorMsg(formatted, op.name(), metalake, getErrorMsg(e)); + LOG.warn(errorMsg, e); + + if (e instanceof IllegalArgumentException) { + return Utils.illegalArguments(errorMsg, e); + + } else if (e instanceof NotFoundException) { + return Utils.notFound(errorMsg, e); + + } else if (e instanceof GroupAlreadyExistsException) { + return Utils.alreadyExists(errorMsg, e); + + } else { + return super.handle(op, group, metalake, e); + } + } + } + @VisibleForTesting static class BaseExceptionHandler extends ExceptionHandler { diff --git a/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java b/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java new file mode 100644 index 00000000000..87fce04adaf --- /dev/null +++ b/server/src/main/java/com/datastrato/gravitino/server/web/rest/GroupOperations.java @@ -0,0 +1,123 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ + +package com.datastrato.gravitino.server.web.rest; + +import com.codahale.metrics.annotation.ResponseMetered; +import com.codahale.metrics.annotation.Timed; +import com.datastrato.gravitino.NameIdentifier; +import com.datastrato.gravitino.dto.requests.GroupCreateRequest; +import com.datastrato.gravitino.dto.responses.DropResponse; +import com.datastrato.gravitino.dto.responses.GroupResponse; +import com.datastrato.gravitino.dto.util.DTOConverters; +import com.datastrato.gravitino.lock.LockType; +import com.datastrato.gravitino.lock.TreeLockUtils; +import com.datastrato.gravitino.metrics.MetricNames; +import com.datastrato.gravitino.server.web.Utils; +import com.datastrato.gravitino.tenant.AccessControlManager; +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Path("/metalakes/{metalake}/groups") +public class GroupOperations { + + private static final Logger LOG = LoggerFactory.getLogger(GroupOperations.class); + + private final AccessControlManager accessControlManager; + + @Context private HttpServletRequest httpRequest; + + @Inject + public GroupOperations(AccessControlManager accessControlManager) { + this.accessControlManager = accessControlManager; + } + + @GET + @Path("{group}") + @Produces("application/vnd.gravitino.v1+json") + @Timed(name = "load-group." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) + @ResponseMetered(name = "load-group", absolute = true) + public Response loadGroup( + @PathParam("metalake") String metalake, @PathParam("group") String group) { + try { + NameIdentifier ident = NameIdentifier.of(metalake, group); + return Utils.doAs( + httpRequest, + () -> + Utils.ok( + new GroupResponse( + DTOConverters.toDTO( + TreeLockUtils.doWithTreeLock( + ident, + LockType.READ, + () -> accessControlManager.loadGroup(metalake, group)))))); + } catch (Exception e) { + return ExceptionHandlers.handleGroupException(OperationType.LOAD, group, metalake, e); + } + } + + @POST + @Produces("application/vnd.gravitino.v1+json") + @Timed(name = "create-group." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) + @ResponseMetered(name = "create-group", absolute = true) + public Response createGroup(@PathParam("metalake") String metalake, GroupCreateRequest request) { + try { + NameIdentifier ident = NameIdentifier.of(metalake, request.getName()); + return Utils.doAs( + httpRequest, + () -> + Utils.ok( + new GroupResponse( + DTOConverters.toDTO( + TreeLockUtils.doWithTreeLock( + ident, + LockType.WRITE, + () -> + accessControlManager.createGroup( + metalake, + request.getName(), + request.getUsers(), + request.getProperties())))))); + } catch (Exception e) { + return ExceptionHandlers.handleGroupException( + OperationType.CREATE, request.getName(), metalake, e); + } + } + + @DELETE + @Path("{group}") + @Produces("application/vnd.gravitino.v1+json") + @Timed(name = "drop-group." + MetricNames.HTTP_PROCESS_DURATION, absolute = true) + @ResponseMetered(name = "drop-group", absolute = true) + public Response dropGroup( + @PathParam("metalake") String metalake, @PathParam("group") String group) { + try { + return Utils.doAs( + httpRequest, + () -> { + NameIdentifier ident = NameIdentifier.of(metalake, group); + boolean dropped = + TreeLockUtils.doWithTreeLock( + ident, LockType.WRITE, () -> accessControlManager.dropGroup(metalake, group)); + if (!dropped) { + LOG.warn("Failed to drop table {} under metalake {}", group, metalake); + } + return Utils.ok(new DropResponse(dropped)); + }); + } catch (Exception e) { + return ExceptionHandlers.handleGroupException(OperationType.DROP, group, metalake, e); + } + } +} diff --git a/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestGroupOperations.java b/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestGroupOperations.java new file mode 100644 index 00000000000..a445d4d7f14 --- /dev/null +++ b/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestGroupOperations.java @@ -0,0 +1,289 @@ +/* + * Copyright 2024 Datastrato Pvt Ltd. + * This software is licensed under the Apache License version 2. + */ +package com.datastrato.gravitino.server.web.rest; + +import static com.datastrato.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL; +import static com.datastrato.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY; +import static com.datastrato.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datastrato.gravitino.Config; +import com.datastrato.gravitino.GravitinoEnv; +import com.datastrato.gravitino.Group; +import com.datastrato.gravitino.dto.GroupDTO; +import com.datastrato.gravitino.dto.requests.GroupCreateRequest; +import com.datastrato.gravitino.dto.responses.DropResponse; +import com.datastrato.gravitino.dto.responses.ErrorConstants; +import com.datastrato.gravitino.dto.responses.ErrorResponse; +import com.datastrato.gravitino.dto.responses.GroupResponse; +import com.datastrato.gravitino.exceptions.GroupAlreadyExistsException; +import com.datastrato.gravitino.exceptions.NoSuchGroupException; +import com.datastrato.gravitino.exceptions.NoSuchMetalakeException; +import com.datastrato.gravitino.lock.LockManager; +import com.datastrato.gravitino.meta.AuditInfo; +import com.datastrato.gravitino.meta.MetalakeGroup; +import com.datastrato.gravitino.rest.RESTUtils; +import com.datastrato.gravitino.tenant.AccessControlManager; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import java.io.IOException; +import java.time.Instant; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.glassfish.hk2.utilities.binding.AbstractBinder; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.glassfish.jersey.test.TestProperties; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class TestGroupOperations extends JerseyTest { + + private final AccessControlManager manager = mock(AccessControlManager.class); + + private static class MockServletRequestFactory extends ServletRequestFactoryBase { + @Override + public HttpServletRequest get() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRemoteUser()).thenReturn(null); + return request; + } + } + + @BeforeAll + public static void setup() { + Config config = mock(Config.class); + Mockito.doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY); + Mockito.doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY); + Mockito.doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL); + GravitinoEnv.getInstance().setLockManager(new LockManager(config)); + } + + @Override + protected Application configure() { + try { + forceSet( + TestProperties.CONTAINER_PORT, String.valueOf(RESTUtils.findAvailablePort(2000, 3000))); + } catch (IOException e) { + throw new RuntimeException(e); + } + + ResourceConfig resourceConfig = new ResourceConfig(); + resourceConfig.register(GroupOperations.class); + resourceConfig.register( + new AbstractBinder() { + @Override + protected void configure() { + bind(manager).to(AccessControlManager.class).ranked(2); + bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class); + } + }); + + return resourceConfig; + } + + @Test + public void testCreateGroup() { + GroupCreateRequest req = + new GroupCreateRequest( + "group", Lists.newArrayList("user"), ImmutableMap.of("key", "value")); + Group group = buildGroup("group"); + + when(manager.createGroup(any(), any(), any(), any())).thenReturn(group); + + Response resp = + target("/metalakes/metalake1/groups") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); + + GroupResponse groupResponse = resp.readEntity(GroupResponse.class); + Assertions.assertEquals(0, groupResponse.getCode()); + + GroupDTO groupDTO = groupResponse.getGroup(); + Assertions.assertEquals("group", groupDTO.name()); + Assertions.assertEquals(ImmutableMap.of("key", "value"), groupDTO.properties()); + + // Test throw NoSuchMetalakeException + doThrow(new NoSuchMetalakeException("mock error")) + .when(manager) + .createGroup(any(), any(), any(), any()); + Response resp1 = + target("/metalakes/metalake1/groups") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), resp1.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp1.getMediaType()); + + ErrorResponse errorResponse = resp1.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, errorResponse.getCode()); + Assertions.assertEquals(NoSuchMetalakeException.class.getSimpleName(), errorResponse.getType()); + + // Test throw GroupAlreadyExistsException + doThrow(new GroupAlreadyExistsException("mock error")) + .when(manager) + .createGroup(any(), any(), any(), any()); + Response resp2 = + target("/metalakes/metalake1/groups") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals(Response.Status.CONFLICT.getStatusCode(), resp2.getStatus()); + + ErrorResponse errorResponse1 = resp2.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.ALREADY_EXISTS_CODE, errorResponse1.getCode()); + Assertions.assertEquals( + GroupAlreadyExistsException.class.getSimpleName(), errorResponse1.getType()); + + // Test throw internal RuntimeException + doThrow(new RuntimeException("mock error")) + .when(manager) + .createGroup(any(), any(), any(), any()); + Response resp3 = + target("/metalakes/metalake1/groups") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); + + Assertions.assertEquals( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp3.getStatus()); + + ErrorResponse errorResponse2 = resp3.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResponse2.getCode()); + Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResponse2.getType()); + } + + @Test + public void testLoadGroup() { + Group group = buildGroup("group"); + when(manager.loadGroup(any(), any())).thenReturn(group); + + Response resp = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .get(); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp.getStatus()); + + GroupResponse groupResponse = resp.readEntity(GroupResponse.class); + Assertions.assertEquals(0, groupResponse.getCode()); + GroupDTO groupDTO = groupResponse.getGroup(); + Assertions.assertEquals("group", groupDTO.name()); + Assertions.assertEquals(ImmutableMap.of("key", "value"), groupDTO.properties()); + + // Test throw NoSuchMetalakeException + doThrow(new NoSuchMetalakeException("mock error")).when(manager).loadGroup(any(), any()); + Response resp1 = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .get(); + + Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), resp1.getStatus()); + + ErrorResponse errorResponse = resp1.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, errorResponse.getCode()); + Assertions.assertEquals(NoSuchMetalakeException.class.getSimpleName(), errorResponse.getType()); + + // Test throw NoSuchGroupException + doThrow(new NoSuchGroupException("mock error")).when(manager).loadGroup(any(), any()); + Response resp2 = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .get(); + + Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), resp2.getStatus()); + + ErrorResponse errorResponse1 = resp2.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, errorResponse1.getCode()); + Assertions.assertEquals(NoSuchGroupException.class.getSimpleName(), errorResponse1.getType()); + + // Test throw internal RuntimeException + doThrow(new RuntimeException("mock error")).when(manager).loadGroup(any(), any()); + Response resp3 = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .get(); + + Assertions.assertEquals( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp3.getStatus()); + + ErrorResponse errorResponse2 = resp3.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResponse2.getCode()); + Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResponse2.getType()); + } + + @Test + public void testDropGroup() { + when(manager.dropGroup(any(), any())).thenReturn(true); + + Response resp = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .delete(); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp.getStatus()); + DropResponse dropResponse = resp.readEntity(DropResponse.class); + Assertions.assertEquals(0, dropResponse.getCode()); + Assertions.assertTrue(dropResponse.dropped()); + + // Test when failed to drop group + when(manager.dropGroup(any(), any())).thenReturn(false); + Response resp2 = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .delete(); + + Assertions.assertEquals(Response.Status.OK.getStatusCode(), resp2.getStatus()); + DropResponse dropResponse2 = resp2.readEntity(DropResponse.class); + Assertions.assertEquals(0, dropResponse2.getCode()); + Assertions.assertFalse(dropResponse2.dropped()); + + doThrow(new RuntimeException("mock error")).when(manager).dropGroup(any(), any()); + Response resp3 = + target("/metalakes/metalake1/groups/group1") + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .delete(); + + Assertions.assertEquals( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp3.getStatus()); + + ErrorResponse errorResponse = resp3.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResponse.getCode()); + Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResponse.getType()); + } + + private Group buildGroup(String group) { + return MetalakeGroup.builder() + .withId(1L) + .withName(group) + .withMetalake("metalake") + .withProperties(ImmutableMap.of("key", "value")) + .withUsers(Lists.newArrayList("user")) + .withAuditInfo( + AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build()) + .build(); + } +} diff --git a/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestUserOperations.java b/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestUserOperations.java index 01b75019e49..7e4543de370 100644 --- a/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestUserOperations.java +++ b/server/src/test/java/com/datastrato/gravitino/server/web/rest/TestUserOperations.java @@ -199,7 +199,7 @@ public void testLoadUser() { Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, errorResponse.getCode()); Assertions.assertEquals(NoSuchMetalakeException.class.getSimpleName(), errorResponse.getType()); - // Test throw NoSuchCatalogException + // Test throw NoSuchUserException doThrow(new NoSuchUserException("mock error")).when(manager).loadUser(any(), any()); Response resp2 = target("/metalakes/metalake1/users/user1") @@ -255,7 +255,7 @@ public void testDropUser() { Assertions.assertEquals(0, dropResponse.getCode()); Assertions.assertTrue(dropResponse.dropped()); - // Test when failed to drop catalog + // Test when failed to drop group when(manager.dropUser(any(), any())).thenReturn(false); Response resp2 = target("/metalakes/metalake1/users/user1")