Skip to content

Commit

Permalink
feat: grant/revoke ADMIN role (#111)
Browse files Browse the repository at this point in the history
  • Loading branch information
guillermocalvo authored May 2, 2024
1 parent 4fe7ca0 commit dfb0fdc
Show file tree
Hide file tree
Showing 18 changed files with 179 additions and 9 deletions.
1 change: 1 addition & 0 deletions http/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
}
dependencies {
api(project(":core"))
api(project(":security-http"))
api(project(":bootstrap"))
implementation("io.micronaut:micronaut-management")

Expand Down
4 changes: 3 additions & 1 deletion http/src/main/resources/views/question/_list.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<th:block th:fragment="list(breadcrumbs, questions)" xmlns:th="http://www.thymeleaf.org">
<nav th:replace="~{bootstrap/_breadcrumbs :: breadcrumbs(${breadcrumbs})}"></nav>
<a href="/question/create"><button type="button" class="btn btn-primary" th:text="#{question.create}"></button></a>
<th:block th:if="${user.roles.contains('ROLE_ADMIN')}">
<a href="/question/create"><button type="button" class="btn btn-primary" th:text="#{question.create}"></button></a>
</th:block>
<th:block th:each="question : ${questions}">
<div th:replace="~{question/_question :: question(${question})}"/>
</th:block>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.projectcheckins.test.AbstractAuthenticationFetcher.ADMIN;
import static org.projectcheckins.test.AbstractAuthenticationFetcher.SDELAMO;
import static org.projectcheckins.test.AssertUtils.redirection;

Expand All @@ -12,13 +13,17 @@
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.StringUtils;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.multitenancy.Tenant;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.filters.AuthenticationFetcher;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
Expand All @@ -31,6 +36,7 @@
import org.projectcheckins.core.repositories.QuestionRepository;
import org.projectcheckins.core.repositories.SecondaryAnswerRepository;
import org.projectcheckins.core.repositories.SecondaryProfileRepository;
import org.projectcheckins.security.http.UserViewModelProcessor;
import org.projectcheckins.test.AbstractAuthenticationFetcher;
import org.projectcheckins.test.BrowserRequest;

Expand All @@ -48,6 +54,16 @@ class QuestionControllerFormTest {
static class AuthenticationFetcherMock extends AbstractAuthenticationFetcher {
}

@Requires(property = "spec.name", value = "QuestionControllerFormTest")
static class UserViewModelProcessorReplacement extends UserViewModelProcessor {
@Inject
private AuthenticationFetcherMock authenticationFetcher;
@Override
protected Optional<Authentication> getAuthentication(HttpRequest<?> request) {
return Optional.of(authenticationFetcher.getAuthentication());
}
}

@Requires(property = "spec.name", value = "QuestionControllerFormTest")
@Singleton
static class ProfileRepositoryMock extends SecondaryProfileRepository {
Expand Down Expand Up @@ -96,9 +112,15 @@ void crud(@Client("/") HttpClient httpClient,
String location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
String id = location.substring(location.indexOf("/question/") + "/question/".length(), location.lastIndexOf("/show"));

authenticationFetcher.setAuthentication(ADMIN);
assertThat(client.retrieve(BrowserRequest.GET(UriBuilder.of("/question").path("list").build()), String.class))
.contains(title)
.contains("/question/create");

authenticationFetcher.setAuthentication(SDELAMO);
assertThat(client.retrieve(BrowserRequest.GET(UriBuilder.of("/question").path("list").build()), String.class))
.contains(title)
.contains("/question/create");
.doesNotContain("/question/create");

assertThat(client.retrieve(BrowserRequest.GET(UriBuilder.of("/question").path(id).path("show").build()), String.class))
.contains(title)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ public void deleteByEmail(@NotBlank @Email String email, @Nullable Tenant tenant
deleteUser(rootProvider.root().getUsers(), user);
}

@Override
public void updateAuthorities(@NotBlank @Email String email,
@NonNull List<String> authorities,
@Nullable Tenant tenant) {
final UserEntity user = findByEmail(email).orElseThrow(UserNotFoundException::new);
user.authorities(authorities);
save(user);
}

@NonNull
private Optional<UserEntity> findByEmail(@NotBlank @Email String email) {
return rootProvider.root().getUsers().stream().filter(u -> u.email().equals(email)).findFirst();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.projectcheckins.security.Role.ROLE_ADMIN;

import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.authentication.ClientAuthentication;
Expand Down Expand Up @@ -41,13 +42,21 @@ void testCrud(EclipseStoreUser storeUser, EclipseStoreProfileRepository profileR
profileRepository.update(rightAuth, new ProfileUpdate(TimeZone.getDefault(), SUNDAY, LocalTime.of(9, 0), LocalTime.of(16, 30), TimeFormat.TWELVE_HOUR_CLOCK, Format.WYSIWYG, "first name", "last name"));
assertThat(profileRepository.findByAuthentication(rightAuth))
.hasValueSatisfying(p -> assertThat(p)
.hasFieldOrPropertyWithValue("admin", false)
.hasFieldOrPropertyWithValue("firstDayOfWeek", SUNDAY)
.hasFieldOrPropertyWithValue("beginningOfDay", LocalTime.of(9, 0))
.hasFieldOrPropertyWithValue("endOfDay", LocalTime.of(16, 30))
.hasFieldOrPropertyWithValue("timeFormat", TimeFormat.TWELVE_HOUR_CLOCK)
.hasFieldOrPropertyWithValue("format", Format.WYSIWYG)
.hasFieldOrPropertyWithValue("firstName", "first name")
.hasFieldOrPropertyWithValue("lastName", "last name"));

profileRepository.updateAuthorities(email, Collections.singletonList(ROLE_ADMIN), null);

assertThat(profileRepository.findByAuthentication(rightAuth))
.hasValueSatisfying(p -> assertThat(p)
.hasFieldOrPropertyWithValue("admin", true));

assertThatThrownBy(() -> profileRepository.update(wrongAuth, new ProfileUpdate(TimeZone.getDefault(), SUNDAY, LocalTime.of(9, 0), LocalTime.of(16, 30), TimeFormat.TWENTY_FOUR_HOUR_CLOCK, Format.MARKDOWN,"first name", "last name")))
.isInstanceOf(UserNotFoundException.class);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
@Serdeable
public record MemberRow(@NonNull @NotNull @Email String email,
@NonNull @NotNull String fullName,
@Nullable Form updateForm,
@Nullable Form deleteForm) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.projectcheckins.security.TeamInvitation;
import org.projectcheckins.security.api.PublicProfile;
import org.projectcheckins.security.forms.TeamMemberDelete;
import org.projectcheckins.security.forms.TeamMemberUpdate;
import org.projectcheckins.security.forms.TeamMemberSave;
import org.projectcheckins.security.forms.TeamInvitationDelete;
import org.projectcheckins.security.services.TeamService;
Expand All @@ -47,6 +48,7 @@ class TeamController {
public static final String ACTION_CREATE = "create";
public static final String ACTION_SAVE = "save";
public static final String ACTION_DELETE = "delete";
public static final String ACTION_UPDATE = "update";
private static final String TEAM = "team";
private static final String INVITATION = "invitation";
public static final String PATH = SLASH + TEAM;
Expand Down Expand Up @@ -79,7 +81,13 @@ class TeamController {

// UNINVITE
private static final String PATH_INVITATION_DELETE = PATH + SLASH + INVITATION + SLASH + ACTION_DELETE;

// UPDATE
private static final String PATH_UPDATE = PATH + SLASH + ACTION_UPDATE;

private static final Message MESSAGE_DELETE = Message.of("Delete", "action.delete");
private static final Message MESSAGE_GRANT_ADMIN = Message.of("Grant Admin privileges", "team.admin.grant");
private static final Message MESSAGE_REVOKE_ADMIN = Message.of("Revoke Admin privileges", "team.admin.revoke");
private final TeamService teamService;
private final FormGenerator formGenerator;
private final HttpHostResolver httpHostResolver;
Expand All @@ -102,6 +110,13 @@ private Form deleteInvitationForm(@NonNull TeamInvitation invitation) {
return formGenerator.generate(PATH_INVITATION_DELETE, new TeamInvitationDelete(invitation.email()), MESSAGE_DELETE);
}

@NonNull
private Form updateMemberForm(@NonNull PublicProfile member) {
final TeamMemberUpdate form = new TeamMemberUpdate(member.email(), !member.isAdmin());
final Message message = member.isAdmin() ? MESSAGE_REVOKE_ADMIN : MESSAGE_GRANT_ADMIN;
return formGenerator.generate(PATH_UPDATE, form, message);
}

@NonNull
private Form deleteMemberForm(@NonNull PublicProfile member) {
return formGenerator.generate(PATH_DELETE, new TeamMemberDelete(member.email()), MESSAGE_DELETE);
Expand Down Expand Up @@ -132,6 +147,12 @@ HttpResponse<?> teamInvitationDelete(@NonNull @NotNull @Valid @Body TeamInvitati
return HttpResponse.seeOther(URI.create(PATH_LIST));
}

@PostForm(uri = PATH_UPDATE, rolesAllowed = ROLE_ADMIN)
HttpResponse<?> memberUpdate(@NonNull @NotNull @Valid @Body TeamMemberUpdate form, @Nullable Tenant tenant) {
teamService.update(form, tenant);
return HttpResponse.seeOther(URI.create(PATH_LIST));
}

@Error(exception = ConstraintViolationException.class)
public HttpResponse<?> onConstraintViolationException(HttpRequest<?> request, ConstraintViolationException ex) {
if (PATH_INVITATION_DELETE.equals(request.getPath())) {
Expand Down Expand Up @@ -164,7 +185,7 @@ private Map<String, Object> listModel(Authentication authentication, @Nullable T
MODEL_BREADCRUMBS, BREADCRUMBS_LIST,
MODEL_MEMBERS, teamService.findAll(tenant)
.stream()
.map(m -> new MemberRow(m.email(), m.fullName(), isAdmin && !m.id().equals(self) ? deleteMemberForm(m) : null))
.map(m -> new MemberRow(m.email(), m.fullName(), isAdmin && !m.id().equals(self) ? updateMemberForm(m) : null, isAdmin && !m.id().equals(self) ? deleteMemberForm(m) : null))
.toList(),
MODEL_INVITATIONS, teamService.findInvitations(tenant)
.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@
import jakarta.inject.Singleton;
import org.projectcheckins.security.MapViewModelProcessor;
import java.util.Map;
import java.util.Optional;

@Singleton
public class UserViewModelProcessor extends MapViewModelProcessor {
private static final String KEY_SECURITY = "user";

@Override
protected void populateModel(HttpRequest<?> request, Map<String, Object> viewModel) {
request.getAttribute(SecurityFilter.AUTHENTICATION, Authentication.class)
getAuthentication(request)
.ifPresent(authentication -> viewModel.put(KEY_SECURITY, authentication));
}

protected Optional<Authentication> getAuthentication(HttpRequest<?> request) {
return request.getAttribute(SecurityFilter.AUTHENTICATION, Authentication.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ loginform.submit=Log in
login.back=Already have an account?
resetpasswordform.email=Enter your email address below and we'll send you password reset instructions.
resetpasswordform.submit=Email me reset instructions
team.admin.grant=Grant Admin privileges
team.admin.revoke=Revoke Admin privileges
team.uninvite.warning=You are about to uninvite this team member. Are you sure you want to continue?
password.forgot.warning=<strong>If you don't see your reset email</strong> be sure to check your spam filter for an email from us.
password.forgot.back=Nevermind, go back
Expand Down
9 changes: 8 additions & 1 deletion security-http/src/main/resources/views/team/_member.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
<span th:unless="${member.fullName.empty}" th:text="${member.fullName}"></span>
<code th:if="${member.fullName.empty}" th:text="${member.email}"></code>
</th>
<td class="text-end">
<td class="text-end" style="width: 1%">
<th:block th:if="${member.updateForm}">
<div>
<form th:replace="~{fieldset/form :: form(${member.updateForm})}"></form>
</div>
</th:block>
</td>
<td class="text-end" style="width: 1%">
<th:block th:if="${member.deleteForm}">
<div>
<form th:replace="~{fieldset/form :: form(${member.deleteForm})}"></form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.projectcheckins.security.forms.TeamMemberDelete;
import org.projectcheckins.security.forms.TeamMemberSave;
import org.projectcheckins.security.forms.TeamInvitationDelete;
import org.projectcheckins.security.forms.TeamMemberUpdate;
import org.projectcheckins.security.services.TeamService;
import org.projectcheckins.security.services.TeamServiceImpl;
import org.projectcheckins.security.TeamInvitationRecord;
Expand Down Expand Up @@ -53,6 +54,7 @@ class TeamControllerTest {
static final String URI_SAVE = UriBuilder.of("/team").path("save").build().toString();
static final String URI_DELETE = UriBuilder.of("/team").path("delete").build().toString();
static final String URI_UNINVITE = UriBuilder.of("/team").path("uninvite").build().toString();
static final String URI_UPDATE = UriBuilder.of("/team").path("update").build().toString();

static final PublicProfile USER_1 = new PublicProfileRecord(
"user1",
Expand Down Expand Up @@ -125,6 +127,12 @@ void testListTeamMembers(@Client("/") HttpClient httpClient) {
.satisfies(htmlPage())
.satisfies(htmlBody("""
<span>User One</span>"""))
.satisfies(htmlBody("""
<form action="/team/delete" method="post">"""))
.satisfies(htmlBody("""
<form action="/team/update" method="post">"""))
.satisfies(htmlBody("Revoke Admin privileges"))
.satisfies(htmlBody("Grant Admin privileges"))
.satisfies(htmlBody("""
<code>[email protected]</code>"""))
.satisfies(htmlBody("""
Expand All @@ -143,10 +151,17 @@ void testListTeamMembersNonAdmin(@Client("/") HttpClient httpClient) {
<span>User One</span>"""))
.satisfies(htmlBody("""
<code>[email protected]</code>"""))
.satisfies(htmlBody(body -> Assertions.assertThat(body).doesNotContain("""
<code>[email protected]</code>""")))
.satisfies(htmlBody(body -> Assertions.assertThat(body).doesNotContain("""
<a href="/team/create">""")));
.satisfies(htmlBody(body -> Assertions.assertThat(body)
.doesNotContain("<code>[email protected]</code>")
.doesNotContain("""
<form action="/team/delete" method="post">""")
.doesNotContain("""
<form action="/team/update" method="post">""")
.doesNotContain("""
<a href="/team/create">""")
.doesNotContain("Revoke Admin privileges")
.doesNotContain("Grant Admin privileges")
));
}

@Test
Expand Down Expand Up @@ -260,6 +275,16 @@ void testRemoveTeamMemberNonAdmin(@Client("/") HttpClient httpClient) {
.satisfies(redirection("/unauthorized"));
}

@Test
void testMemberUpdate(@Client("/") HttpClient httpClient) {
final BlockingHttpClient client = httpClient.toBlocking();
authMock.setAuthentication(AbstractAuthenticationFetcher.ADMIN);
final Map<String, Object> body = Map.of("email", "[email protected]", "isAdmin", true);
final HttpRequest<?> request = BrowserRequest.POST(URI_UPDATE, body);
Assertions.assertThat(client.exchange(request, String.class))
.satisfies(redirection(URI_LIST));
}

@Requires(property = "spec.name", value = "TeamControllerTest")
@Singleton
static class AuthenticationFetcherMock extends AbstractAuthenticationFetcher {
Expand Down Expand Up @@ -313,6 +338,9 @@ public void remove(@NotNull @Valid TeamMemberDelete form, @Nullable Tenant tenan
public void uninvite(@NotNull @Valid TeamInvitationDelete form, @Nullable Tenant tenant) {

}

@Override
public void update(@NotNull @Valid TeamMemberUpdate form, @Nullable Tenant tenant) {}
}

record PublicProfileRecord(String id, String email, String fullName, boolean isAdmin) implements PublicProfile {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package org.projectcheckins.security;

import io.micronaut.context.annotation.Secondary;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.multitenancy.Tenant;
import jakarta.inject.Singleton;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

import java.util.List;

@Secondary
@Singleton
public class SecondaryUserRepository implements UserRepository {
Expand All @@ -19,4 +22,11 @@ public boolean existsByEmail(@NotBlank @Email String email, @Nullable Tenant ten
public void deleteByEmail(@NotBlank @Email String email, @Nullable Tenant tenant) {
throw new UnsupportedOperationException("Not implemented");
}

@Override
public void updateAuthorities(@NotBlank @Email String email,
@NonNull List<String> authorities,
@Nullable Tenant tenant) {
throw new UnsupportedOperationException("Not implemented");
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package org.projectcheckins.security;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.multitenancy.Tenant;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

import java.util.List;

public interface UserRepository {
boolean existsByEmail(@NotBlank @Email String email, @Nullable Tenant tenant);

void deleteByEmail(@NotBlank @Email String email, @Nullable Tenant tenant);

void updateAuthorities(@NotBlank @Email String email,
@NonNull List<String> authorities,
@Nullable Tenant tenant);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.projectcheckins.security.forms;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.serde.annotation.Serdeable;
import io.micronaut.views.fields.annotations.InputHidden;
import jakarta.validation.constraints.NotBlank;

@Serdeable
public record TeamMemberUpdate(@NonNull @NotBlank @InputHidden String email, @InputHidden boolean isAdmin) { }
Loading

0 comments on commit dfb0fdc

Please sign in to comment.