diff --git a/bootstrap/sql/mysql/v004__create_db_connection_info.sql b/bootstrap/sql/mysql/v004__create_db_connection_info.sql new file mode 100644 index 00000000000..140842a190b --- /dev/null +++ b/bootstrap/sql/mysql/v004__create_db_connection_info.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS role_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> '$.id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.name') NOT NULL, + json JSON NOT NULL, + updatedAt TIMESTAMP GENERATED ALWAYS AS (TIMESTAMP(STR_TO_DATE(json ->> '$.updatedAt', '%Y-%m-%dT%T.%fZ'))) NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.updatedBy') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY unique_name(name), + INDEX (updatedBy), + INDEX (updatedAt) +); + +ALTER TABLE role_entity +ADD COLUMN deleted BOOLEAN GENERATED ALWAYS AS (JSON_EXTRACT(json, '$.deleted')), +ADD INDEX (deleted); \ No newline at end of file diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/Entity.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/Entity.java index 41790455ef2..f7213b43d7f 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/Entity.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/Entity.java @@ -68,8 +68,9 @@ public final class Entity { public static final String POLICY = "policy"; // - // Team/user + // Role, team and user // + public static final String ROLE = "role"; public static final String USER = "user"; public static final String TEAM = "team"; diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java index d54e0a0c35d..53506019fda 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java @@ -44,6 +44,7 @@ import org.openmetadata.catalog.entity.services.DatabaseService; import org.openmetadata.catalog.entity.services.MessagingService; import org.openmetadata.catalog.entity.services.PipelineService; import org.openmetadata.catalog.entity.services.StorageService; +import org.openmetadata.catalog.entity.teams.Role; import org.openmetadata.catalog.entity.teams.Team; import org.openmetadata.catalog.entity.teams.User; import org.openmetadata.catalog.jdbi3.BotsRepository.BotsEntityInterface; @@ -62,6 +63,7 @@ import org.openmetadata.catalog.jdbi3.PipelineRepository.PipelineEntityInterface import org.openmetadata.catalog.jdbi3.PipelineServiceRepository.PipelineServiceEntityInterface; import org.openmetadata.catalog.jdbi3.PolicyRepository.PolicyEntityInterface; import org.openmetadata.catalog.jdbi3.ReportRepository.ReportEntityInterface; +import org.openmetadata.catalog.jdbi3.RoleRepository.RoleEntityInterface; import org.openmetadata.catalog.jdbi3.StorageServiceRepository.StorageServiceEntityInterface; import org.openmetadata.catalog.jdbi3.TableRepository.TableEntityInterface; import org.openmetadata.catalog.jdbi3.TeamRepository.TeamEntityInterface; @@ -89,6 +91,9 @@ public interface CollectionDAO { @CreateSqlObject EntityExtensionDAO entityExtensionDAO(); + @CreateSqlObject + RoleDAO roleDAO(); + @CreateSqlObject UserDAO userDAO(); @@ -894,6 +899,28 @@ public interface CollectionDAO { } } + interface RoleDAO extends EntityDAO { + @Override + default String getTableName() { + return "role_entity"; + } + + @Override + default Class getEntityClass() { + return Role.class; + } + + @Override + default String getNameColumn() { + return "name"; + } + + @Override + default EntityReference getEntityReference(Role entity) { + return new RoleEntityInterface(entity).getEntityReference(); + } + } + interface TeamDAO extends EntityDAO { @Override default String getTableName() { diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/RoleRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/RoleRepository.java new file mode 100644 index 00000000000..d0daeff91ed --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/RoleRepository.java @@ -0,0 +1,230 @@ +/* + * Copyright 2021 Collate + * Licensed 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.openmetadata.catalog.jdbi3; + +import java.io.IOException; +import java.net.URI; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.openmetadata.catalog.Entity; +import org.openmetadata.catalog.entity.teams.Role; +import org.openmetadata.catalog.exception.CatalogExceptionMessage; +import org.openmetadata.catalog.resources.teams.RoleResource; +import org.openmetadata.catalog.type.ChangeDescription; +import org.openmetadata.catalog.type.EntityReference; +import org.openmetadata.catalog.type.TagLabel; +import org.openmetadata.catalog.util.EntityInterface; +import org.openmetadata.catalog.util.EntityUtil.Fields; +import org.openmetadata.catalog.util.JsonUtils; + +public class RoleRepository extends EntityRepository { + static final Fields ROLE_UPDATE_FIELDS = new Fields(RoleResource.FIELD_LIST, null); + static final Fields ROLE_PATCH_FIELDS = new Fields(RoleResource.FIELD_LIST, null); + private final CollectionDAO dao; + + public RoleRepository(CollectionDAO dao) { + super( + RoleResource.COLLECTION_PATH, + Entity.ROLE, + Role.class, + dao.roleDAO(), + dao, + ROLE_PATCH_FIELDS, + ROLE_UPDATE_FIELDS); + this.dao = dao; + } + + @Override + public Role setFields(Role role, Fields fields) throws IOException { + // Nothing to set. + return role; + } + + @Override + public void restorePatchAttributes(Role original, Role updated) { + // Patch can't make changes to following fields. Ignore the changes + updated.withName(original.getName()).withId(original.getId()); + } + + @Override + public EntityInterface getEntityInterface(Role entity) { + return new RoleEntityInterface(entity); + } + + @Override + public void prepare(Role role) throws IOException {} + + @Override + public void storeEntity(Role role, boolean update) throws IOException { + // Don't store href as JSON. Build it on the fly based on relationships + role.withHref(null); + + if (update) { + dao.roleDAO().update(role.getId(), JsonUtils.pojoToJson(role)); + } else { + dao.roleDAO().insert(role); + } + } + + @Override + public void storeRelationships(Role role) {} + + @Override + public EntityUpdater getUpdater(Role original, Role updated, boolean patchOperation) { + return new RoleUpdater(original, updated, patchOperation); + } + + public static class RoleEntityInterface implements EntityInterface { + private final Role entity; + + public RoleEntityInterface(Role entity) { + this.entity = entity; + } + + @Override + public UUID getId() { + return entity.getId(); + } + + @Override + public String getDescription() { + return entity.getDescription(); + } + + @Override + public String getDisplayName() { + return entity.getDisplayName(); + } + + @Override + public EntityReference getOwner() { + return null; + } + + @Override + public String getFullyQualifiedName() { + return entity.getName(); + } + + @Override + public List getTags() { + return null; + } + + @Override + public Double getVersion() { + return entity.getVersion(); + } + + @Override + public String getUpdatedBy() { + return entity.getUpdatedBy(); + } + + @Override + public Date getUpdatedAt() { + return entity.getUpdatedAt(); + } + + @Override + public URI getHref() { + return entity.getHref(); + } + + @Override + public List getFollowers() { + throw new UnsupportedOperationException("Role does not support followers"); + } + + @Override + public EntityReference getEntityReference() { + return new EntityReference() + .withId(getId()) + .withName(getFullyQualifiedName()) + .withDescription(getDescription()) + .withDisplayName(getDisplayName()) + .withType(Entity.ROLE) + .withHref(getHref()); + } + + @Override + public Role getEntity() { + return entity; + } + + @Override + public void setId(UUID id) { + entity.setId(id); + } + + @Override + public void setDescription(String description) { + entity.setDescription(description); + } + + @Override + public void setDisplayName(String displayName) { + entity.setDisplayName(displayName); + } + + @Override + public void setUpdateDetails(String updatedBy, Date updatedAt) { + entity.setUpdatedBy(updatedBy); + entity.setUpdatedAt(updatedAt); + } + + @Override + public void setChangeDescription(Double newVersion, ChangeDescription changeDescription) { + entity.setVersion(newVersion); + entity.setChangeDescription(changeDescription); + } + + @Override + public void setOwner(EntityReference owner) {} + + @Override + public void setDeleted(boolean flag) { + entity.setDeleted(flag); + } + + @Override + public Role withHref(URI href) { + return entity.withHref(href); + } + + @Override + public ChangeDescription getChangeDescription() { + return entity.getChangeDescription(); + } + + @Override + public void setTags(List tags) {} + } + + /** Handles entity updated from PUT and POST operation. */ + public class RoleUpdater extends EntityUpdater { + public RoleUpdater(Role original, Role updated, boolean patchOperation) { + super(original, updated, patchOperation); + } + + @Override + public void entitySpecificUpdate() throws IOException { + // Update operation cannot undelete a role. + if (updated.getEntity().getDeleted() != original.getEntity().getDeleted()) { + throw new IllegalArgumentException(CatalogExceptionMessage.readOnlyAttribute("Role", "deleted")); + } + } + } +} diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/RoleResource.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/RoleResource.java new file mode 100644 index 00000000000..0ef0760f49f --- /dev/null +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/teams/RoleResource.java @@ -0,0 +1,326 @@ +/* + * Copyright 2021 Collate + * Licensed 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.openmetadata.catalog.resources.teams; + +import com.google.inject.Inject; +import io.dropwizard.jersey.PATCH; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import javax.json.JsonPatch; +import javax.validation.Valid; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; +import org.openmetadata.catalog.api.teams.CreateRole; +import org.openmetadata.catalog.entity.teams.Role; +import org.openmetadata.catalog.jdbi3.CollectionDAO; +import org.openmetadata.catalog.jdbi3.RoleRepository; +import org.openmetadata.catalog.resources.Collection; +import org.openmetadata.catalog.security.Authorizer; +import org.openmetadata.catalog.security.SecurityUtil; +import org.openmetadata.catalog.type.EntityHistory; +import org.openmetadata.catalog.util.EntityUtil; +import org.openmetadata.catalog.util.RestUtil; +import org.openmetadata.catalog.util.RestUtil.PatchResponse; +import org.openmetadata.catalog.util.ResultList; + +@Path("/v1/roles") +@Api(value = "Roles collection", tags = "Roles collection") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "roles") +public class RoleResource { + public static final String COLLECTION_PATH = "/v1/roles/"; + private final RoleRepository dao; + private final Authorizer authorizer; + + @Inject + public RoleResource(CollectionDAO dao, Authorizer authorizer) { + Objects.requireNonNull(dao, "RoleRepository must not be null"); + this.dao = new RoleRepository(dao); + this.authorizer = authorizer; + } + + public static class RoleList extends ResultList { + @SuppressWarnings("unused") /* Required for tests */ + RoleList() {} + + public RoleList(List roles, String beforeCursor, String afterCursor, int total) + throws GeneralSecurityException, UnsupportedEncodingException { + super(roles, beforeCursor, afterCursor, total); + } + } + + // No additional fields supported for role entity at the moment. + public static final List FIELD_LIST = new ArrayList<>(); + + @GET + @Valid + @Operation( + summary = "List roles", + tags = "roles", + description = + "Get a list of roles. Use cursor-based pagination to limit the number of entries in the list using `limit`" + + " and `before` or `after` query params.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of roles", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = RoleList.class))) + }) + public ResultList list( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Limit the number tables returned. (1 to 1000000, default = 10)") + @DefaultValue("10") + @Min(1) + @Max(1000000) + @QueryParam("limit") + int limitParam, + @Parameter(description = "Returns list of tables before this cursor", schema = @Schema(type = "string")) + @QueryParam("before") + String before, + @Parameter(description = "Returns list of tables after this cursor", schema = @Schema(type = "string")) + @QueryParam("after") + String after) + throws IOException, GeneralSecurityException, ParseException { + RestUtil.validateCursors(before, after); + EntityUtil.Fields fields = new EntityUtil.Fields(FIELD_LIST, null); + + ResultList roles; + if (before != null) { // Reverse paging + roles = dao.listBefore(uriInfo, fields, null, limitParam, before); // Ask for one extra entry + } else { // Forward paging or first page + roles = dao.listAfter(uriInfo, fields, null, limitParam, after); + } + return roles; + } + + @GET + @Path("/{id}/versions") + @Operation( + summary = "List role versions", + tags = "roles", + description = "Get a list of all the versions of a role identified by `id`", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of role versions", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = EntityHistory.class))) + }) + public EntityHistory listVersions( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "role Id", schema = @Schema(type = "string")) @PathParam("id") String id) + throws IOException, ParseException { + return dao.listVersions(id); + } + + @GET + @Valid + @Path("/{id}") + @Operation( + summary = "Get a role", + tags = "roles", + description = "Get a role by `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The role", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Role.class))), + @ApiResponse(responseCode = "404", description = "Role for instance {id} is not found") + }) + public Role get(@Context UriInfo uriInfo, @Context SecurityContext securityContext, @PathParam("id") String id) + throws IOException, ParseException { + EntityUtil.Fields fields = new EntityUtil.Fields(FIELD_LIST, null); + return dao.get(uriInfo, id, fields); + } + + @GET + @Valid + @Path("/name/{name}") + @Operation( + summary = "Get a role by name", + tags = "roles", + description = "Get a role by `name`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The role", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Role.class))), + @ApiResponse(responseCode = "404", description = "Role for instance {name} is not found") + }) + public Role getByName( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, @PathParam("name") String name) + throws IOException, ParseException { + EntityUtil.Fields fields = new EntityUtil.Fields(FIELD_LIST, null); + return dao.getByName(uriInfo, name, fields); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + summary = "Get a version of the role", + tags = "roles", + description = "Get a version of the role by given `id`", + responses = { + @ApiResponse( + responseCode = "200", + description = "role", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Role.class))), + @ApiResponse( + responseCode = "404", + description = "Role for instance {id} and version {version} is " + "not found") + }) + public Role getVersion( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Role Id", schema = @Schema(type = "string")) @PathParam("id") String id, + @Parameter( + description = "Role version number in the form `major`.`minor`", + schema = @Schema(type = "string", example = "0.1 or 1.1")) + @PathParam("version") + String version) + throws IOException, ParseException { + return dao.getVersion(id, version); + } + + @POST + @Operation( + summary = "Create a role", + tags = "roles", + description = "Create a new role.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The role", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = CreateRole.class))), + @ApiResponse(responseCode = "400", description = "Bad request") + }) + public Response create( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateRole createRole) + throws IOException { + SecurityUtil.checkAdminOrBotRole(authorizer, securityContext); + Role role = getRole(createRole, securityContext); + role = dao.create(uriInfo, role); + return Response.created(role.getHref()).entity(role).build(); + } + + @PUT + @Operation( + summary = "Create or Update a role", + tags = "roles", + description = "Create or Update a role.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The role ", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = CreateRole.class))), + @ApiResponse(responseCode = "400", description = "Bad request") + }) + public Response createOrUpdateRole( + @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateRole createRole) + throws IOException, ParseException { + SecurityUtil.checkAdminOrBotRole(authorizer, securityContext); + Role role = getRole(createRole, securityContext); + RestUtil.PutResponse response = dao.createOrUpdate(uriInfo, role); + return response.toResponse(); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation( + summary = "Update a role", + tags = "roles", + description = "Update an existing role with JsonPatch.", + externalDocs = @ExternalDocumentation(description = "JsonPatch RFC", url = "https://tools.ietf.org/html/rfc6902")) + public Response patch( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @PathParam("id") String id, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = { + @ExampleObject("[" + "{op:remove, path:/a}," + "{op:add, path: /b, value: val}" + "]") + })) + JsonPatch patch) + throws IOException, ParseException { + + SecurityUtil.checkAdminOrBotRole(authorizer, securityContext); + PatchResponse response = + dao.patch(uriInfo, UUID.fromString(id), securityContext.getUserPrincipal().getName(), patch); + return response.toResponse(); + } + + @DELETE + @Path("/{id}") + @Operation( + summary = "Delete a role", + tags = "roles", + description = "Delete a role by given `id`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Role for instance {id} is not found") + }) + public Response delete(@Context UriInfo uriInfo, @Context SecurityContext securityContext, @PathParam("id") String id) + throws IOException { + SecurityUtil.checkAdminOrBotRole(authorizer, securityContext); + dao.delete(UUID.fromString(id)); + return Response.ok().build(); + } + + private Role getRole(CreateRole ct, SecurityContext securityContext) { + return new Role() + .withId(UUID.randomUUID()) + .withName(ct.getName()) + .withDescription(ct.getDescription()) + .withDisplayName(ct.getDisplayName()) + .withUpdatedBy(securityContext.getUserPrincipal().getName()) + .withUpdatedAt(new Date()); + } +} diff --git a/catalog-rest-service/src/main/resources/json/schema/api/teams/createRole.json b/catalog-rest-service/src/main/resources/json/schema/api/teams/createRole.json new file mode 100644 index 00000000000..7772bf2b55e --- /dev/null +++ b/catalog-rest-service/src/main/resources/json/schema/api/teams/createRole.json @@ -0,0 +1,21 @@ +{ + "$id": "https://github.com/open-metadata/OpenMetadata/blob/main/catalog-rest-service/src/main/resources/json/schema/api/teams/createRole.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Create role entity request", + "description": "Role entity", + "type": "object", + "properties" : { + "name": { + "$ref": "../../entity/teams/role.json#/definitions/roleName" + }, + "displayName": { + "description": "Optional name used for display purposes. Example 'Data Consumer'", + "type": "string" + }, + "description": { + "description": "Optional description of the role", + "type": "string" + } + }, + "required": ["name"] +} \ No newline at end of file diff --git a/catalog-rest-service/src/main/resources/json/schema/entity/teams/role.json b/catalog-rest-service/src/main/resources/json/schema/entity/teams/role.json new file mode 100644 index 00000000000..60222dd9879 --- /dev/null +++ b/catalog-rest-service/src/main/resources/json/schema/entity/teams/role.json @@ -0,0 +1,61 @@ +{ + "$id": "https://open-metadata.org/schema/entity/teams/role.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Role", + "description": "This schema defines the Role entity. A Role has access to zero or more data assets", + "type": "object", + "definitions" : { + "roleName" : { + "description": "A unique name of the role.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "properties" : { + "id": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "$ref": "#/definitions/roleName" + }, + "displayName": { + "description": "Name used for display purposes. Example 'Data Consumer'.", + "type": "string" + }, + "description": { + "description": "Description of the role.", + "type": "string" + }, + "version" : { + "description": "Metadata version of the entity.", + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt" : { + "description": "Last update time corresponding to the new version of the entity.", + "$ref": "../../type/basic.json#/definitions/dateTime" + }, + "updatedBy" : { + "description": "User who made the update.", + "type": "string" + }, + "href": { + "description": "Link to the resource corresponding to this entity.", + "$ref": "../../type/basic.json#/definitions/href" + }, + "changeDescription": { + "description" : "Change that lead to this version of the entity.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted" : { + "description": "When `true` indicates the entity has been soft deleted.", + "type": "boolean", + "default" : false + } + }, + "required" : [ + "id", + "name" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/RoleResourceTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/RoleResourceTest.java new file mode 100644 index 00000000000..9a5610e08c2 --- /dev/null +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/teams/RoleResourceTest.java @@ -0,0 +1,224 @@ +/* + * Copyright 2021 Collate + * Licensed 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.openmetadata.catalog.resources.teams; + +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.FORBIDDEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openmetadata.catalog.security.SecurityUtil.authHeaders; +import static org.openmetadata.catalog.util.TestUtils.adminAuthHeaders; +import static org.openmetadata.catalog.util.TestUtils.assertListNotNull; +import static org.openmetadata.catalog.util.TestUtils.assertResponse; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import javax.json.JsonPatch; +import javax.ws.rs.client.WebTarget; +import org.apache.http.client.HttpResponseException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.openmetadata.catalog.CatalogApplicationTest; +import org.openmetadata.catalog.Entity; +import org.openmetadata.catalog.api.teams.CreateRole; +import org.openmetadata.catalog.entity.teams.Role; +import org.openmetadata.catalog.exception.CatalogExceptionMessage; +import org.openmetadata.catalog.jdbi3.RoleRepository.RoleEntityInterface; +import org.openmetadata.catalog.resources.EntityResourceTest; +import org.openmetadata.catalog.resources.teams.RoleResource.RoleList; +import org.openmetadata.catalog.type.EntityReference; +import org.openmetadata.catalog.util.EntityInterface; +import org.openmetadata.catalog.util.JsonUtils; +import org.openmetadata.catalog.util.TestUtils; +import org.openmetadata.common.utils.JsonSchemaUtil; + +public class RoleResourceTest extends EntityResourceTest { + + public RoleResourceTest() { + super(Entity.ROLE, Role.class, RoleList.class, "roles", null, false, false, false); + } + + @Test + public void post_validRoles_as_admin_200_OK(TestInfo test) throws IOException { + // Create role with different optional fields + CreateRole create = create(test, 1); + createAndCheckEntity(create, adminAuthHeaders()); + + create = create(test, 2).withDisplayName("displayName"); + createAndCheckEntity(create, adminAuthHeaders()); + + create = create(test, 3).withDescription("description"); + createAndCheckEntity(create, adminAuthHeaders()); + + create = create(test, 4).withDisplayName("displayName").withDescription("description"); + createAndCheckEntity(create, adminAuthHeaders()); + } + + @Test + public void post_validRoles_as_non_admin_401(TestInfo test) { + // Create role with different optional fields + Map authHeaders = authHeaders("test@open-metadata.org"); + CreateRole create = create(test, 1); + HttpResponseException exception = + assertThrows(HttpResponseException.class, () -> createAndCheckEntity(create, authHeaders)); + assertResponse(exception, FORBIDDEN, "Principal: CatalogPrincipal{name='test'} is not admin"); + } + + /** + * @see EntityResourceTest#put_addDeleteFollower_200 for tests related getting role with entities owned by the role + */ + @Test + public void delete_validRole_200_OK(TestInfo test) throws IOException { + CreateRole create = create(test); + Role role = createAndCheckEntity(create, adminAuthHeaders()); + deleteEntity(role.getId(), adminAuthHeaders()); + } + + @Test + public void delete_validRole_as_non_admin_401(TestInfo test) throws IOException { + CreateRole create = create(test); + Role role = createAndCheckEntity(create, adminAuthHeaders()); + HttpResponseException exception = + assertThrows( + HttpResponseException.class, () -> deleteEntity(role.getId(), authHeaders("test@open-metadata.org"))); + assertResponse(exception, FORBIDDEN, "Principal: CatalogPrincipal{name='test'} is not admin"); + } + + @Test + public void patch_roleDeletedDisallowed_400(TestInfo test) throws HttpResponseException, JsonProcessingException { + // Ensure role deleted attribute can't be changed using patch + Role role = createRole(create(test), adminAuthHeaders()); + String roleJson = JsonUtils.pojoToJson(role); + role.setDeleted(true); + HttpResponseException exception = + assertThrows(HttpResponseException.class, () -> patchRole(roleJson, role, adminAuthHeaders())); + assertResponse(exception, BAD_REQUEST, CatalogExceptionMessage.readOnlyAttribute("Role", "deleted")); + } + + @Test + public void patch_roleAttributes_as_non_admin_403(TestInfo test) + throws HttpResponseException, JsonProcessingException { + // Create table without any attributes + Role role = createRole(create(test), adminAuthHeaders()); + // Patching as a non-admin should is disallowed + String originalJson = JsonUtils.pojoToJson(role); + role.setDisplayName("newDisplayName"); + HttpResponseException exception = + assertThrows( + HttpResponseException.class, + () -> patchRole(role.getId(), originalJson, role, authHeaders("test@open-metadata.org"))); + assertResponse(exception, FORBIDDEN, "Principal: CatalogPrincipal{name='test'} is not admin"); + } + + public static Role createRole(CreateRole create, Map authHeaders) throws HttpResponseException { + return TestUtils.post(CatalogApplicationTest.getResource("roles"), create, Role.class, authHeaders); + } + + public static Role getRole(UUID id, String fields, Map authHeaders) throws HttpResponseException { + WebTarget target = CatalogApplicationTest.getResource("roles/" + id); + target = fields != null ? target.queryParam("fields", fields) : target; + return TestUtils.get(target, Role.class, authHeaders); + } + + public static Role getRoleByName(String name, String fields, Map authHeaders) + throws HttpResponseException { + WebTarget target = CatalogApplicationTest.getResource("roles/name/" + name); + target = fields != null ? target.queryParam("fields", fields) : target; + return TestUtils.get(target, Role.class, authHeaders); + } + + private static void validateRole( + Role role, String expectedDescription, String expectedDisplayName, String expectedUpdatedBy) { + assertListNotNull(role.getId(), role.getHref()); + assertEquals(expectedDescription, role.getDescription()); + assertEquals(expectedUpdatedBy, role.getUpdatedBy()); + assertEquals(expectedDisplayName, role.getDisplayName()); + } + + /** Validate returned fields GET .../roles/{id}?fields="..." or GET .../roles/name/{name}?fields="..." */ + @Override + public void validateGetWithDifferentFields(Role expectedRole, boolean byName) throws HttpResponseException { + String updatedBy = TestUtils.getPrincipal(adminAuthHeaders()); + // Role does not have any supported additional fields yet. + // .../roles + Role getRole = + byName + ? getRoleByName(expectedRole.getName(), null, adminAuthHeaders()) + : getRole(expectedRole.getId(), null, adminAuthHeaders()); + validateRole(getRole, expectedRole.getDescription(), expectedRole.getDisplayName(), updatedBy); + } + + private Role patchRole(UUID roleId, String originalJson, Role updated, Map authHeaders) + throws JsonProcessingException, HttpResponseException { + String updatedJson = JsonUtils.pojoToJson(updated); + JsonPatch patch = JsonSchemaUtil.getJsonPatch(originalJson, updatedJson); + return TestUtils.patch(CatalogApplicationTest.getResource("roles/" + roleId), patch, Role.class, authHeaders); + } + + private Role patchRole(String originalJson, Role updated, Map authHeaders) + throws JsonProcessingException, HttpResponseException { + return patchRole(updated.getId(), originalJson, updated, authHeaders); + } + + CreateRole create(TestInfo test, int index) { + return new CreateRole().withName(getEntityName(test) + index); + } + + public CreateRole create(TestInfo test) { + return create(getEntityName(test)); + } + + public CreateRole create(String entityName) { + return new CreateRole().withName(entityName); + } + + @Override + public Object createRequest(String name, String description, String displayName, EntityReference owner) { + return create(name).withDescription(description).withDisplayName(displayName); + } + + @Override + public void validateCreatedEntity(Role role, Object request, Map authHeaders) { + CreateRole createRequest = (CreateRole) request; + validateCommonEntityFields( + getEntityInterface(role), createRequest.getDescription(), TestUtils.getPrincipal(authHeaders), null); + + assertEquals(createRequest.getDisplayName(), role.getDisplayName()); + } + + @Override + public void validateUpdatedEntity(Role updatedEntity, Object request, Map authHeaders) { + validateCreatedEntity(updatedEntity, request, authHeaders); + } + + @Override + public void compareEntities(Role expected, Role updated, Map authHeaders) { + validateCommonEntityFields( + getEntityInterface(updated), expected.getDescription(), TestUtils.getPrincipal(authHeaders), null); + + assertEquals(expected.getDisplayName(), updated.getDisplayName()); + } + + @Override + public EntityInterface getEntityInterface(Role entity) { + return new RoleEntityInterface(entity); + } + + @Override + public void assertFieldChange(String fieldName, Object expected, Object actual) throws IOException { + assertCommonFieldChange(fieldName, expected, actual); + } +}