diff --git a/src/main/java/us/kbase/auth2/service/api/APIPaths.java b/src/main/java/us/kbase/auth2/service/api/APIPaths.java index acfeb1d5..3238e134 100644 --- a/src/main/java/us/kbase/auth2/service/api/APIPaths.java +++ b/src/main/java/us/kbase/auth2/service/api/APIPaths.java @@ -42,6 +42,9 @@ public class APIPaths { public static final String API_V2_ADMIN = API_V2 + SEP + ADMIN; /** The anonymous ID lookup endpoint location relative to the admin root. */ public static final String ANONYMOUS_ID_LOOKUP = "anonids"; + + /** The admin user roles endpoint location relative to the admin root. */ + public static final String ADMIN_USER_ROLES = USERS + SEP + "{" + USERNAME + "}" + SEP + "roles"; /** The token introspection endpoint location. */ public static final String API_V2_TOKEN = API_V2 + SEP + TOKEN; diff --git a/src/main/java/us/kbase/auth2/service/api/Admin.java b/src/main/java/us/kbase/auth2/service/api/Admin.java index 6b00df58..7a398137 100644 --- a/src/main/java/us/kbase/auth2/service/api/Admin.java +++ b/src/main/java/us/kbase/auth2/service/api/Admin.java @@ -5,28 +5,40 @@ import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import javax.inject.Inject; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; 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.MediaType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + import us.kbase.auth2.lib.Authentication; +import us.kbase.auth2.lib.Role; import us.kbase.auth2.lib.UserName; import us.kbase.auth2.lib.exceptions.DisabledUserException; import us.kbase.auth2.lib.exceptions.IllegalParameterException; import us.kbase.auth2.lib.exceptions.InvalidTokenException; +import us.kbase.auth2.lib.exceptions.MissingParameterException; +import us.kbase.auth2.lib.exceptions.NoSuchRoleException; +import us.kbase.auth2.lib.exceptions.NoSuchUserException; import us.kbase.auth2.lib.exceptions.NoTokenProvidedException; import us.kbase.auth2.lib.exceptions.UnauthorizedException; import us.kbase.auth2.lib.storage.exceptions.AuthStorageException; import us.kbase.auth2.service.common.Fields; +import us.kbase.auth2.service.common.IncomingJSON; @Path(APIPaths.API_V2_ADMIN) public class Admin { @@ -71,4 +83,96 @@ static Set processAnonymousIDListString(final String anonIDs) return ids; } + /** Request body for updating user roles. */ + public static class UpdateUserRoles extends IncomingJSON { + + public final List addRoles; + public final List removeRoles; + public final List addCustomRoles; + public final List removeCustomRoles; + + @JsonCreator + public UpdateUserRoles( + @JsonProperty(Fields.ADD_ROLES) final List addRoles, + @JsonProperty(Fields.REMOVE_ROLES) final List removeRoles, + @JsonProperty(Fields.ADD_CUSTOM_ROLES) final List addCustomRoles, + @JsonProperty(Fields.REMOVE_CUSTOM_ROLES) final List removeCustomRoles) { + this.addRoles = addRoles; + this.removeRoles = removeRoles; + this.addCustomRoles = addCustomRoles; + this.removeCustomRoles = removeCustomRoles; + } + } + + @POST + @Path(APIPaths.ADMIN_USER_ROLES) + @Consumes(MediaType.APPLICATION_JSON) + public void updateUserRoles( + @HeaderParam(APIConstants.HEADER_TOKEN) final String token, + @PathParam(APIPaths.USERNAME) final String userName, + final UpdateUserRoles update) + throws NoTokenProvidedException, InvalidTokenException, AuthStorageException, + UnauthorizedException, NoSuchUserException, NoSuchRoleException, + IllegalParameterException, MissingParameterException, DisabledUserException { + + if (update == null) { + throw new MissingParameterException("JSON body missing"); + } + update.exceptOnAdditionalProperties(); + + final UserName user = new UserName(userName); + + // Convert string lists to appropriate sets, handling nulls + final List addRolesList = update.addRoles == null ? + Collections.emptyList() : update.addRoles; + final List removeRolesList = update.removeRoles == null ? + Collections.emptyList() : update.removeRoles; + final List addCustomList = update.addCustomRoles == null ? + Collections.emptyList() : update.addCustomRoles; + final List removeCustomList = update.removeCustomRoles == null ? + Collections.emptyList() : update.removeCustomRoles; + + noNulls(addRolesList, "Null item in roles"); + noNulls(removeRolesList, "Null item in roles"); + noNulls(addCustomList, "Null item in custom roles"); + noNulls(removeCustomList, "Null item in custom roles"); + + final Set addRoles = toRoles(addRolesList); + final Set removeRoles = toRoles(removeRolesList); + + // Update built-in roles if any specified + if (!addRoles.isEmpty() || !removeRoles.isEmpty()) { + auth.updateRoles(getToken(token), user, addRoles, removeRoles); + } + + // Update custom roles if any specified + if (!addCustomList.isEmpty() || !removeCustomList.isEmpty()) { + auth.updateCustomRoles( + getToken(token), user, + new HashSet<>(addCustomList), + new HashSet<>(removeCustomList)); + } + } + + private Set toRoles(final List roles) throws IllegalParameterException { + final Set ret = new HashSet<>(); + for (final String role : roles) { + try { + ret.add(Role.getRole(role)); + } catch (IllegalArgumentException e) { + throw new IllegalParameterException(e.getMessage(), e); + } + } + return ret; + } + + private void noNulls(final List list, final String message) + throws IllegalParameterException { + for (final String item : list) { + if (item == null) { + throw new IllegalParameterException(message); + } + } + } + } diff --git a/src/main/java/us/kbase/auth2/service/common/Fields.java b/src/main/java/us/kbase/auth2/service/common/Fields.java index 8bb8a3f2..18cb34df 100644 --- a/src/main/java/us/kbase/auth2/service/common/Fields.java +++ b/src/main/java/us/kbase/auth2/service/common/Fields.java @@ -127,6 +127,14 @@ public class Fields { * names from conflicting with other items in the form. */ public static final String CUSTOM_ROLE_FORM_PREFIX = "crole_"; + /** Roles to add to a user. */ + public static final String ADD_ROLES = "addRoles"; + /** Roles to remove from a user. */ + public static final String REMOVE_ROLES = "removeRoles"; + /** Custom roles to add to a user. */ + public static final String ADD_CUSTOM_ROLES = "addCustomRoles"; + /** Custom roles to remove from a user. */ + public static final String REMOVE_CUSTOM_ROLES = "removeCustomRoles"; /* search */ diff --git a/src/test/java/us/kbase/test/auth2/service/api/AdminIntegrationTest.java b/src/test/java/us/kbase/test/auth2/service/api/AdminIntegrationTest.java index bfc68f89..d14c67a1 100644 --- a/src/test/java/us/kbase/test/auth2/service/api/AdminIntegrationTest.java +++ b/src/test/java/us/kbase/test/auth2/service/api/AdminIntegrationTest.java @@ -12,6 +12,7 @@ import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; import javax.ws.rs.client.Invocation.Builder; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -29,7 +30,10 @@ import us.kbase.auth2.lib.PasswordHashAndSalt; import us.kbase.auth2.lib.Role; import us.kbase.auth2.lib.UserName; +import us.kbase.auth2.lib.exceptions.IllegalParameterException; +import us.kbase.auth2.lib.exceptions.NoTokenProvidedException; import us.kbase.auth2.lib.exceptions.UnauthorizedException; +import us.kbase.auth2.lib.user.AuthUser; import us.kbase.auth2.lib.token.IncomingToken; import us.kbase.auth2.lib.token.StoredToken; import us.kbase.auth2.lib.token.TokenType; @@ -165,4 +169,134 @@ public void translateAnonIDsToUserNamesFailNotAdmin() throws Exception { failRequestJSON(req.get(), 403, "Forbidden", new UnauthorizedException()); } + + /* updateUserRoles integration tests */ + + @Test + public void updateUserRolesSuccess() throws Exception { + final PasswordHashAndSalt pwd = new PasswordHashAndSalt( + "foobarbazbing".getBytes(), "aa".getBytes()); + + // Create admin user + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + new UserName("admin"), UUID.randomUUID(), new DisplayName("Admin"), inst(20000)) + .withRole(Role.ADMIN) + .build(), + pwd); + + // Create target user with DevToken role (to be removed) + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + new UserName("targetuser"), UUID.randomUUID(), new DisplayName("Target"), inst(20000)) + .withRole(Role.DEV_TOKEN) + .build(), + pwd); + + final IncomingToken token = new IncomingToken("admintoken"); + manager.storage.storeToken(StoredToken.getBuilder(TokenType.LOGIN, UUID.randomUUID(), + new UserName("admin")).withLifeTime(inst(10000), inst(1000000000000000L)).build(), + token.getHashedToken().getTokenHash()); + + final URI target = UriBuilder.fromUri(host) + .path("/api/V2/admin/users/targetuser/roles") + .build(); + + final String body = "{\"addRoles\": [\"ServToken\"], \"removeRoles\": [\"DevToken\"]}"; + + final Response res = CLI.target(target).request() + .header("authorization", token.getToken()) + .header("content-type", MediaType.APPLICATION_JSON) + .post(Entity.json(body)); + + assertThat("incorrect response code", res.getStatus(), is(204)); + + // Verify roles were updated + final AuthUser user = manager.storage.getUser(new UserName("targetuser")); + assertThat("should have ServToken", user.hasRole(Role.SERV_TOKEN), is(true)); + assertThat("should not have DevToken", user.hasRole(Role.DEV_TOKEN), is(false)); + } + + @Test + public void updateUserRolesFailNotAdmin() throws Exception { + final PasswordHashAndSalt pwd = new PasswordHashAndSalt( + "foobarbazbing".getBytes(), "aa".getBytes()); + + // Create non-admin user + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + new UserName("nonadmin"), UUID.randomUUID(), new DisplayName("NonAdmin"), inst(20000)) + .build(), + pwd); + + // Create target user + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + new UserName("targetuser"), UUID.randomUUID(), new DisplayName("Target"), inst(20000)) + .build(), + pwd); + + final IncomingToken token = new IncomingToken("usertoken"); + manager.storage.storeToken(StoredToken.getBuilder(TokenType.LOGIN, UUID.randomUUID(), + new UserName("nonadmin")).withLifeTime(inst(10000), inst(1000000000000000L)).build(), + token.getHashedToken().getTokenHash()); + + final URI target = UriBuilder.fromUri(host) + .path("/api/V2/admin/users/targetuser/roles") + .build(); + + final Response res = CLI.target(target).request() + .header("accept", MediaType.APPLICATION_JSON) + .header("authorization", token.getToken()) + .header("content-type", MediaType.APPLICATION_JSON) + .post(Entity.json("{\"addRoles\": [\"Admin\"]}")); + + // Non-admin users cannot grant Admin role - the error message includes the user and role + failRequestJSON(res, 403, "Forbidden", new UnauthorizedException( + "User nonadmin is not authorized to grant role(s): Administrator")); + } + + @Test + public void updateUserRolesFailNoToken() throws Exception { + final URI target = UriBuilder.fromUri(host) + .path("/api/V2/admin/users/testuser/roles") + .build(); + + final Response res = CLI.target(target).request() + .header("accept", MediaType.APPLICATION_JSON) + .header("content-type", MediaType.APPLICATION_JSON) + .post(Entity.json("{\"addRoles\": [\"Admin\"]}")); + + // NoTokenProvidedException extends AuthException (not AuthenticationException) + // so it maps to 400 Bad Request + failRequestJSON(res, 400, "Bad Request", + new NoTokenProvidedException("No user token provided")); + } + + @Test + public void updateUserRolesFailInvalidRole() throws Exception { + final PasswordHashAndSalt pwd = new PasswordHashAndSalt( + "foobarbazbing".getBytes(), "aa".getBytes()); + + // Create admin user + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + new UserName("admin"), UUID.randomUUID(), new DisplayName("Admin"), inst(20000)) + .withRole(Role.ADMIN) + .build(), + pwd); + + final IncomingToken token = new IncomingToken("admintoken"); + manager.storage.storeToken(StoredToken.getBuilder(TokenType.LOGIN, UUID.randomUUID(), + new UserName("admin")).withLifeTime(inst(10000), inst(1000000000000000L)).build(), + token.getHashedToken().getTokenHash()); + + final URI target = UriBuilder.fromUri(host) + .path("/api/V2/admin/users/admin/roles") + .build(); + + final Response res = CLI.target(target).request() + .header("accept", MediaType.APPLICATION_JSON) + .header("authorization", token.getToken()) + .header("content-type", MediaType.APPLICATION_JSON) + .post(Entity.json("{\"addRoles\": [\"NotARealRole\"]}")); + + failRequestJSON(res, 400, "Bad Request", + new IllegalParameterException("Invalid role id: NotARealRole")); + } } diff --git a/src/test/java/us/kbase/test/auth2/service/api/AdminTest.java b/src/test/java/us/kbase/test/auth2/service/api/AdminTest.java index 9e18da36..a2f871b1 100644 --- a/src/test/java/us/kbase/test/auth2/service/api/AdminTest.java +++ b/src/test/java/us/kbase/test/auth2/service/api/AdminTest.java @@ -4,12 +4,16 @@ import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; import static us.kbase.test.auth2.TestCommon.set; +import java.util.Arrays; import java.util.Collections; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.junit.Test; @@ -17,11 +21,14 @@ import com.google.common.collect.ImmutableMap; import us.kbase.auth2.lib.Authentication; +import us.kbase.auth2.lib.Role; import us.kbase.auth2.lib.UserName; import us.kbase.auth2.lib.exceptions.IllegalParameterException; +import us.kbase.auth2.lib.exceptions.MissingParameterException; import us.kbase.auth2.lib.exceptions.NoTokenProvidedException; import us.kbase.auth2.lib.token.IncomingToken; import us.kbase.auth2.service.api.Admin; +import us.kbase.auth2.service.api.Admin.UpdateUserRoles; import us.kbase.test.auth2.TestCommon; public class AdminTest { @@ -114,4 +121,209 @@ private void anonIDsToUserNamesFailContains( TestCommon.assertExceptionMessageContains(got, expected); } } + + /* updateUserRoles tests */ + + @Test + public void updateUserRolesNullBody() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + updateUserRolesFail(admin, "token", "username", null, + new MissingParameterException("JSON body missing")); + } + + @Test + public void updateUserRolesInvalidRole() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + Arrays.asList("InvalidRole"), null, null, null); + + updateUserRolesFail(admin, "token", "username", update, + new IllegalParameterException("Invalid role id: InvalidRole")); + } + + @Test + public void updateUserRolesNullInRolesList() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + Arrays.asList("Admin", null), null, null, null); + + updateUserRolesFail(admin, "token", "username", update, + new IllegalParameterException("Null item in roles")); + } + + @Test + public void updateUserRolesNullInCustomRolesList() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + null, null, Arrays.asList("custom1", null), null); + + updateUserRolesFail(admin, "token", "username", update, + new IllegalParameterException("Null item in custom roles")); + } + + @Test + public void updateUserRolesAdditionalProperties() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles(null, null, null, null); + update.setAdditionalProperties("unexpected", "value"); + + updateUserRolesFail(admin, "token", "username", update, + new IllegalParameterException("Unexpected parameters in request: unexpected")); + } + + @Test + public void updateUserRolesSuccess() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + Arrays.asList("Admin"), + Arrays.asList("DevToken"), + Arrays.asList("custom1"), + Arrays.asList("custom2")); + + admin.updateUserRoles("token", "testuser", update); + + verify(auth).updateRoles( + new IncomingToken("token"), + new UserName("testuser"), + set(Role.ADMIN), + set(Role.DEV_TOKEN)); + + verify(auth).updateCustomRoles( + new IncomingToken("token"), + new UserName("testuser"), + set("custom1"), + set("custom2")); + } + + @Test + public void updateUserRolesOnlyBuiltIn() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + Arrays.asList("Admin", "ServToken"), null, null, null); + + admin.updateUserRoles("token", "testuser", update); + + verify(auth).updateRoles( + new IncomingToken("token"), + new UserName("testuser"), + set(Role.ADMIN, Role.SERV_TOKEN), + Collections.emptySet()); + + verify(auth, never()).updateCustomRoles(any(), any(), any(), any()); + } + + @Test + public void updateUserRolesOnlyCustom() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + null, null, Arrays.asList("custom1"), Arrays.asList("custom2")); + + admin.updateUserRoles("token", "testuser", update); + + verify(auth, never()).updateRoles(any(), any(), any(), any()); + + verify(auth).updateCustomRoles( + new IncomingToken("token"), + new UserName("testuser"), + set("custom1"), + set("custom2")); + } + + @Test + public void updateUserRolesEmptyLists() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + + admin.updateUserRoles("token", "testuser", update); + + // Neither method should be called when all lists are empty + verify(auth, never()).updateRoles(any(), any(), any(), any()); + verify(auth, never()).updateCustomRoles(any(), any(), any(), any()); + } + + @Test + public void updateUserRolesOnlyRemoveBuiltIn() throws Exception { + // Tests the second branch of: !addRoles.isEmpty() || !removeRoles.isEmpty() + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + null, Arrays.asList("DevToken"), null, null); + + admin.updateUserRoles("token", "testuser", update); + + verify(auth).updateRoles( + new IncomingToken("token"), + new UserName("testuser"), + Collections.emptySet(), + set(Role.DEV_TOKEN)); + + verify(auth, never()).updateCustomRoles(any(), any(), any(), any()); + } + + @Test + public void updateUserRolesOnlyRemoveCustom() throws Exception { + // Tests the second branch of: !addCustomRoles.isEmpty() || !removeCustomRoles.isEmpty() + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + null, null, null, Arrays.asList("customToRemove")); + + admin.updateUserRoles("token", "testuser", update); + + verify(auth, never()).updateRoles(any(), any(), any(), any()); + + verify(auth).updateCustomRoles( + new IncomingToken("token"), + new UserName("testuser"), + Collections.emptySet(), + set("customToRemove")); + } + + @Test + public void updateUserRolesNoToken() throws Exception { + final Authentication auth = mock(Authentication.class); + final Admin admin = new Admin(auth); + + final UpdateUserRoles update = new UpdateUserRoles( + Arrays.asList("Admin"), null, null, null); + + updateUserRolesFail(admin, null, "username", update, + new NoTokenProvidedException("No user token provided")); + } + + private void updateUserRolesFail( + final Admin admin, + final String token, + final String userName, + final UpdateUserRoles update, + final Exception expected) { + try { + admin.updateUserRoles(token, userName, update); + fail("expected exception"); + } catch (Exception got) { + TestCommon.assertExceptionCorrect(got, expected); + } + } }