diff --git a/Makefile b/Makefile index beaf54b51..f3d4e719d 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ java/registry/target/registry.jar: $(SOURCES) test: build @docker-compose down + @rm -rf db-data* || echo "no permission to delete" # test with ES & standard definition manager @RELEASE_VERSION=latest KEYCLOAK_IMPORT_DIR=java/apitest/src/test/resources KEYCLOAK_SECRET=a52c5f4a-89fd-40b9-aea2-3f711f14c889 DB_DIR=db-data-1 docker-compose up -d @echo "Starting the test" && sh build/wait_for_port.sh 8080 diff --git a/imports/realm-export.json b/imports/realm-export.json index 65655d843..b13c84a55 100644 --- a/imports/realm-export.json +++ b/imports/realm-export.json @@ -468,86 +468,6 @@ "realmRoles": [], "clientRoles": {}, "subGroups": [] - }, - { - "id": "5ea4e456-bf79-4a16-8903-ce6b95d9f328", - "name": "controller", - "path": "/controller", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, - "subGroups": [] - }, - { - "id": "860560da-911e-4db1-92f2-0cbbaad8f7b3", - "name": "facility admin", - "path": "/facility admin", - "attributes": {}, - "realmRoles": [], - "clientRoles": { - "realm-management": [ - "manage-users" - ] - }, - "subGroups": [] - }, - { - "id": "84682840-4c21-428d-b2d5-06e6298be1cc", - "name": "facility staff", - "path": "/facility staff", - "attributes": {}, - "realmRoles": [], - "clientRoles": { - "realm-management": [ - "view-users" - ] - }, - "subGroups": [] - }, - { - "id": "ee4cbc2d-f6a0-4424-9fcd-5dd876c2580c", - "name": "monitoring", - "path": "/monitoring", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, - "subGroups": [] - }, - { - "id": "7a859744-fae3-4d24-9f1c-4aeed92ba383", - "name": "recipient", - "path": "/recipient", - "attributes": {}, - "realmRoles": [], - "clientRoles": { - "certificate-login": [ - "recipient" - ] - }, - "subGroups": [] - }, - { - "id": "691ef8ed-110d-4855-84a6-7c68171e9579", - "name": "system admin", - "path": "/system admin", - "attributes": {}, - "realmRoles": [], - "clientRoles": { - "account": [ - "manage-account", - "view-profile" - ] - }, - "subGroups": [] - }, - { - "id": "dd163e2a-7bc6-4509-be7d-c18055bf3049", - "name": "test", - "path": "/test", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, - "subGroups": [] } ], "defaultRole": { @@ -612,7 +532,8 @@ ], "clientRoles": { "realm-management": [ - "manage-users" + "manage-users", + "manage-realm" ], "admin-api": [ "api" @@ -2987,4 +2908,4 @@ "clientPolicies": { "policies": [] } -} \ No newline at end of file +} diff --git a/java/apitest/src/test/java/e2e/registry/inviteFlow.json b/java/apitest/src/test/java/e2e/registry/inviteFlow.json new file mode 100644 index 000000000..c8a0a1ea0 --- /dev/null +++ b/java/apitest/src/test/java/e2e/registry/inviteFlow.json @@ -0,0 +1,22 @@ +{ + "boardSchema": { + "name": "Board", + "schema": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema\",\n \"type\": \"object\",\n \"properties\": {\n \"Board\": {\n \"$ref\": \"#/definitions/Board\"\n }\n },\n \"required\": [\n \"Board\"\n ],\n \"title\":\"Board\",\n \"definitions\": {\n \"Board\": {\n \"$id\": \"#/properties/Board\",\n \"type\": \"object\",\n \"title\": \"The Board Schema\",\n \"required\": [\n\n ],\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"email\": {\n \"type\": \"string\"\n },\n \"mobile\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"_osConfig\": {\n \"osComment\": [\"This section contains the OpenSABER specific configuration information\",\n \"privateFields: Optional; list of field names to be encrypted and stored in database\",\n \"signedFields: Optional; list of field names that must be pre-signed\",\n \"indexFields: Optional; list of field names used for creating index. Enclose within braces to indicate it is a composite index. In this definition, (serialNum, studentCode) is a composite index and studentName is a single column index.\",\n \"uniqueIndexFields: Optional; list of field names used for creating unique index. Field names must be different from index field name\",\n \"systemFields: Optional; list of fields names used for system standard information like created, updated timestamps and userid\"],\n\n \"privateFields\": [\n\n ],\n \"signedFields\": [],\n \"indexFields\": [],\n \"uniqueIndexFields\": [],\n \"systemFields\": [\n \"_osCreatedAt\",\n \"_osUpdatedAt\",\n \"_osCreatedBy\",\n \"_osUpdatedBy\",\n \"_osAttestedData\",\n \"_osClaimId\",\n \"_osState\"\n ],\n \"inviteRoles\":[\"anonymous\"],\n \"enableLogin\": true,\n \"ownershipAttributes\": [\n {\n \"email\": \"/email\",\n \"mobile\": \"/mobile\",\n \"userId\": \"/mobile\"\n }\n ]\n }\n}", + "status": "PUBLISHED" + }, + "boardInviteRequest": { + "name": "cbse_board", + "email": "t@mail.com", + "mobile": "123" + }, + "instituteSchema": { + "name": "Institute", + "schema": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema\",\n \"type\": \"object\",\n \"properties\": {\n \"Institute\": {\n \"$ref\": \"#/definitions/Institute\"\n }\n },\n \"required\": [\n \"Institute\"\n ],\n \"title\":\"Institute\",\n \"definitions\": {\n \"Institute\": {\n \"$id\": \"#/properties/Institute\",\n \"type\": \"object\",\n \"title\": \"The Institute Schema\",\n \"required\": [\n\n ],\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"email\": {\n \"type\": \"string\"\n },\n \"mobile\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"_osConfig\": {\n \"osComment\": [\"This section contains the OpenSABER specific configuration information\",\n \"privateFields: Optional; list of field names to be encrypted and stored in database\",\n \"signedFields: Optional; list of field names that must be pre-signed\",\n \"indexFields: Optional; list of field names used for creating index. Enclose within braces to indicate it is a composite index. In this definition, (serialNum, studentCode) is a composite index and studentName is a single column index.\",\n \"uniqueIndexFields: Optional; list of field names used for creating unique index. Field names must be different from index field name\",\n \"systemFields: Optional; list of fields names used for system standard information like created, updated timestamps and userid\"],\n\n \"privateFields\": [\n\n ],\n \"signedFields\": [],\n \"indexFields\": [],\n \"uniqueIndexFields\": [],\n \"systemFields\": [\n \"_osCreatedAt\",\n \"_osUpdatedAt\",\n \"_osCreatedBy\",\n \"_osUpdatedBy\",\n \"_osAttestedData\",\n \"_osClaimId\",\n \"_osState\"\n ],\n \"inviteRoles\":[\"Board\"],\n \"enableLogin\": true,\n \"ownershipAttributes\": [\n {\n \"email\": \"/email\",\n \"mobile\": \"/mobile\",\n \"userId\": \"/mobile\"\n }\n ]\n }\n}", + "status": "PUBLISHED" + }, + "instituteRequest": { + "name": "test_institute", + "email": "test_institute@mail.com", + "mobile": "456" + } +} diff --git a/java/apitest/src/test/java/e2e/registry/registry.feature b/java/apitest/src/test/java/e2e/registry/registry.feature index 15ea5a8b2..45381e5cc 100644 --- a/java/apitest/src/test/java/e2e/registry/registry.feature +++ b/java/apitest/src/test/java/e2e/registry/registry.feature @@ -181,8 +181,8 @@ Feature: Registry api tests When method get Then status 404 -@env=async -Scenario: Create a teacher schema and create teacher entity asynchronously + @env=async + Scenario: Create a teacher schema and create teacher entity asynchronously # get admin token * url authUrl * path 'auth/realms/sunbird-rc/protocol/openid-connect/token' @@ -220,3 +220,97 @@ Scenario: Create a teacher schema and create teacher entity asynchronously Then status 200 * print response And response.length == 1 + + Scenario: Create Board and invite institutes + # get admin token + * url authUrl + * path 'auth/realms/sunbird-rc/protocol/openid-connect/token' + * header Content-Type = 'application/x-www-form-urlencoded; charset=utf-8' + * header Host = 'keycloak:8080' + * form field grant_type = 'client_credentials' + * form field client_id = 'admin-api' + * form field client_secret = 'a52c5f4a-89fd-40b9-aea2-3f711f14c889' + * method post + * def sample = read('inviteFlow.json') + Then status 200 + And print response.access_token + * def admin_token = 'Bearer ' + response.access_token + # create board schema + Given url registryUrl + And path 'api/v1/Schema' + And header Authorization = admin_token + And request sample.boardSchema + When method post + Then status 200 + And response.params.status == "SUCCESSFUL" + # create institute schema + Given url registryUrl + And path 'api/v1/Schema' + And header Authorization = admin_token + And request sample.instituteSchema + When method post + Then status 200 + And response.params.status == "SUCCESSFUL" + # invite institute without token should fail + Given url registryUrl + And path 'api/v1/Institute/invite' + And request sample.instituteRequest + When method post + Then status 401 + # invite board + Given url registryUrl + And path 'api/v1/Board/invite' + And request sample.boardInviteRequest + When method post + Then status 200 + # get board token + * url authUrl + * path 'auth/realms/sunbird-rc/protocol/openid-connect/token' + * header Content-Type = 'application/x-www-form-urlencoded; charset=utf-8' + * header Host = 'keycloak:8080' + * form field grant_type = 'password' + * form field client_id = 'registry-frontend' + * form field username = sample.boardInviteRequest.mobile + * form field password = 'abcd@123' + * method post + Then status 200 + And print response.access_token + * def board_token = 'Bearer ' + response.access_token + * sleep(3000) + # get board info + Given url registryUrl + And path 'api/v1/Board' + And header Authorization = board_token + When method get + Then status 200 + And response[0].osid.length > 0 + + # invite institute with token + Given url registryUrl + And path 'api/v1/Institute/invite' + And request sample.instituteRequest + And header Authorization = board_token + When method post + Then status 200 + # get institute token + * url authUrl + * path 'auth/realms/sunbird-rc/protocol/openid-connect/token' + * header Content-Type = 'application/x-www-form-urlencoded; charset=utf-8' + * header Host = 'keycloak:8080' + * form field grant_type = 'password' + * form field client_id = 'registry-frontend' + * form field username = sample.instituteRequest.mobile + * form field password = 'abcd@123' + * method post + Then status 200 + And print response.access_token + * def institute_token = 'Bearer ' + response.access_token + * sleep(3000) + # get institute info + Given url registryUrl + And path 'api/v1/Institute' + And header Authorization = institute_token + When method get + Then status 200 + And response[0].osid.length > 0 + diff --git a/java/apitest/src/test/resources/realm-export.json b/java/apitest/src/test/resources/realm-export.json index efbc9b41d..2584fdee3 100644 --- a/java/apitest/src/test/resources/realm-export.json +++ b/java/apitest/src/test/resources/realm-export.json @@ -621,7 +621,8 @@ ], "clientRoles": { "realm-management": [ - "manage-users" + "manage-users", + "manage-realm" ], "admin-api": [ "api" @@ -2996,4 +2997,4 @@ "clientPolicies": { "policies": [] } -} \ No newline at end of file +} diff --git a/java/middleware/registry-middleware/keycloak/src/main/java/dev/sunbirdrc/keycloak/KeycloakAdminUtil.java b/java/middleware/registry-middleware/keycloak/src/main/java/dev/sunbirdrc/keycloak/KeycloakAdminUtil.java index 5b436d025..e4f775110 100644 --- a/java/middleware/registry-middleware/keycloak/src/main/java/dev/sunbirdrc/keycloak/KeycloakAdminUtil.java +++ b/java/middleware/registry-middleware/keycloak/src/main/java/dev/sunbirdrc/keycloak/KeycloakAdminUtil.java @@ -2,13 +2,18 @@ import dev.sunbirdrc.pojos.ComponentHealthInfo; import dev.sunbirdrc.pojos.HealthIndicator; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; +import org.jboss.resteasy.client.jaxrs.engines.ApacheHttpClient43Engine; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; import org.keycloak.admin.client.resource.*; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,6 +23,7 @@ import org.springframework.stereotype.Component; import java.util.*; +import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.core.Response; import static dev.sunbirdrc.registry.middleware.util.Constants.CONNECTION_FAILURE; @@ -83,9 +89,8 @@ private Keycloak buildKeycloak(int httpMaxConnections) { public String createUser(String entityName, String userName, String email, String mobile) throws OwnerCreationException { logger.info("Creating user with mobile_number : " + userName); + String groupId = createOrUpdateRealmGroup(entityName); UserRepresentation newUser = createUserRepresentation(entityName, userName, email, mobile); - GroupRepresentation entityGroup = createGroupRepresentation(entityName); - keycloak.realm(realm).groups().add(entityGroup); UsersResource usersResource = keycloak.realm(realm).users(); try (Response response = usersResource.create(newUser)) { if (response.getStatus() == 201) { @@ -98,7 +103,7 @@ public String createUser(String entityName, String userName, String email, Strin return userID; } else if (response.getStatus() == 409) { logger.info("UserID: {} exists", userName); - return updateExistingUserAttributes(entityName, userName, email, mobile); + return updateExistingUserAttributes(entityName, userName, email, mobile, groupId); } else if (response.getStatus() == 500) { throw new OwnerCreationException("Keycloak user creation error"); } else { @@ -107,13 +112,40 @@ public String createUser(String entityName, String userName, String email, Strin } } - private GroupRepresentation createGroupRepresentation(String entityName) { + private String createOrUpdateRealmGroup(String entityName) { + RoleRepresentation roleRepresentation = createOrGetRealmRole(entityName); + GroupsResource groupsResource = keycloak.realm(realm).groups(); GroupRepresentation groupRepresentation = new GroupRepresentation(); groupRepresentation.setName(entityName); - return groupRepresentation; + Response groupAddResponse = groupsResource.add(groupRepresentation); + String groupId = ""; + if (groupAddResponse.getStatus() == 409) { + Optional groupRepresentationOptional = groupsResource.groups().stream().filter(gp -> gp.getName().equalsIgnoreCase(entityName)).findFirst(); + if (groupRepresentationOptional.isPresent()) { + groupId = groupRepresentationOptional.get().getId(); + } + } else { + groupId = groupAddResponse.getLocation().getPath().replaceAll(".*/([^/]+)$", "$1"); + } + groupsResource.group(groupId) + .roles().realmLevel().add(Collections.singletonList(roleRepresentation)); + return groupId; } - private String updateExistingUserAttributes(String entityName, String userName, String email, String mobile) throws OwnerCreationException { + private RoleRepresentation createOrGetRealmRole(String entityName) { + RolesResource rolesResource = keycloak.realm(realm).roles(); + try { + RoleRepresentation roleRepresentation = new RoleRepresentation(); + roleRepresentation.setName(entityName); + rolesResource.create(roleRepresentation); + } catch (Exception e){ + logger.error("Role creation exception", e); + } + return rolesResource.get(entityName).toRepresentation(); + } + + private String updateExistingUserAttributes(String entityName, String userName, String email, String mobile, + String groupId) throws OwnerCreationException { Optional userRepresentationOptional = getUserByUsername(userName); if (userRepresentationOptional.isPresent()) { UserResource userResource = userRepresentationOptional.get(); @@ -128,6 +160,7 @@ private String updateExistingUserAttributes(String entityName, String userName, userRepresentation.setGroups(groups); } userResource.update(userRepresentation); + userResource.joinGroup(groupId); return userRepresentation.getId(); } else { logger.error("Failed fetching user by username: {}", userName); @@ -154,16 +187,20 @@ private UserRepresentation createUserRepresentation(String entityName, String us } private void updateUserAttributes(String entityName, String email, String mobile, UserRepresentation userRepresentation) { - List entities = userRepresentation.getAttributes().getOrDefault(ENTITY, Collections.emptyList()); - if (!entities.contains(entityName)) { - entities.add(entityName); + if (userRepresentation.getAttributes() == null || !userRepresentation.getAttributes().containsKey(ENTITY)) { + userRepresentation.singleAttribute(ENTITY, entityName); + } else { + List entities = userRepresentation.getAttributes().getOrDefault(ENTITY, Collections.emptyList()); + if (!entities.contains(entityName)) { + entities.add(entityName); + } } addAttributeIfNotExists(userRepresentation, EMAIL, email); addAttributeIfNotExists(userRepresentation, MOBILE_NUMBER, mobile); } private void addAttributeIfNotExists(UserRepresentation userRepresentation, String key, String value) { - if (!userRepresentation.getAttributes().containsKey(key)) { + if (userRepresentation.getAttributes() == null || !userRepresentation.getAttributes().containsKey(key)) { userRepresentation.singleAttribute(key, value); } }