From ee7973e15528c1846c97f97d20c237d8cf86460b Mon Sep 17 00:00:00 2001 From: Mikel Alejo Date: Tue, 18 Feb 2025 16:50:44 +0100 Subject: [PATCH] RHCLOUD-37536 | feature: authorization annotation for improved maintainability (#3293) * feature: authorization annotation for improved maintainability The implemented annotation allows specifying the legacy RBAC role that will be used when RBAC is enabled, and the Kessel permissions for when that one is being used as the authorization back end. The goal is to reduce the boilerplate and repetitive code that we have in the code base, and make it easier for the developers to add authorization in a similar way that it is done with the "RolesAllowed" annotation. * chore: fix typo RHCLOUD-37536 Co-authored-by: Guillaume Duval <117720964+g-duval@users.noreply.github.com> --------- Co-authored-by: Guillaume Duval <117720964+g-duval@users.noreply.github.com> --- .../auth/annotation/Authorization.java | 40 ++ .../annotation/AuthorizationInterceptor.java | 157 ++++++ .../auth/annotation/IntegrationId.java | 15 + .../auth/annotation/ParameterIndexes.java | 30 ++ .../AuthorizationInterceptorHelper.java | 42 ++ .../AuthorizationInterceptorTest.java | 453 ++++++++++++++++++ .../auth/kessel/KesselTestHelper.java | 11 + 7 files changed, 748 insertions(+) create mode 100644 backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/Authorization.java create mode 100644 backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptor.java create mode 100644 backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/IntegrationId.java create mode 100644 backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/ParameterIndexes.java create mode 100644 backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorHelper.java create mode 100644 backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorTest.java diff --git a/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/Authorization.java b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/Authorization.java new file mode 100644 index 0000000000..cdd18f39b2 --- /dev/null +++ b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/Authorization.java @@ -0,0 +1,40 @@ +package com.redhat.cloud.notifications.auth.annotation; + +import com.redhat.cloud.notifications.auth.kessel.permission.IntegrationPermission; +import com.redhat.cloud.notifications.auth.kessel.permission.WorkspacePermission; +import jakarta.enterprise.util.Nonbinding; +import jakarta.interceptor.InterceptorBinding; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines the annotation to be able to perform permission checks with Kessel + * at the method level. + */ +@InterceptorBinding +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface Authorization { + /** + * The Kessel integration permissions defined by the developer. These + * permissions require the method's integration's identifier to be + * annotated with the {@link IntegrationId} annotation to work. + * @return an array of defined integration permissions. + */ + @Nonbinding IntegrationPermission[] integrationPermissions() default {}; + + /** + * The legacy RBAC role that will be checked when Kessel is disabled. + * @return the legacy RBAC role set for the method. + */ + @Nonbinding String legacyRBACRole(); + + /** + * The Kessel workspace permissions defined by the developer. + * @return an array of defined workspace permissions. + */ + @Nonbinding WorkspacePermission[] workspacePermissions() default {}; +} diff --git a/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptor.java b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptor.java new file mode 100644 index 0000000000..bef9f8d190 --- /dev/null +++ b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptor.java @@ -0,0 +1,157 @@ +package com.redhat.cloud.notifications.auth.annotation; + +import com.redhat.cloud.notifications.auth.kessel.KesselAuthorization; +import com.redhat.cloud.notifications.auth.kessel.permission.IntegrationPermission; +import com.redhat.cloud.notifications.auth.kessel.permission.WorkspacePermission; +import com.redhat.cloud.notifications.auth.rbac.workspace.WorkspaceUtils; +import com.redhat.cloud.notifications.config.BackendConfig; +import com.redhat.cloud.notifications.routers.SecurityContextUtil; +import jakarta.annotation.Priority; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.SecurityContext; + +import java.lang.reflect.Parameter; +import java.util.UUID; + +/** + * The interceptor's implementation for the {@link Authorization} annotaiton. + * On any method annotated with the aforementioned annotation, it will perform + * authorization checks depending on whether RBAC or Kessel authorization back + * ends are enabled. + */ +@Interceptor +@Authorization(legacyRBACRole = "") +@Priority(Interceptor.Priority.APPLICATION) +public class AuthorizationInterceptor { + + private final BackendConfig backendConfig; + private final KesselAuthorization kesselAuthorization; + private final WorkspaceUtils workspaceUtils; + + /** + * Constructor for the interceptor. Helps with testing, as otherwise the + * interceptor cannot get its dependencies injected. + * @param backendConfig the back end's configuration bean. + * @param kesselAuthorization the Kessel's authorization bean. + * @param workspaceUtils the workspace utils' bean. + */ + public AuthorizationInterceptor( + final BackendConfig backendConfig, + final KesselAuthorization kesselAuthorization, + final WorkspaceUtils workspaceUtils + ) { + this.backendConfig = backendConfig; + this.kesselAuthorization = kesselAuthorization; + this.workspaceUtils = workspaceUtils; + } + + @AroundInvoke + public Object aroundInvoke(final InvocationContext ctx) throws Exception { + // Grab the annotation from the method. The annotation will never be + // null, because the interceptor only works with those methods that + // have been annotated. + final Authorization annotation = ctx.getMethod().getDeclaredAnnotation(Authorization.class); + + // Get the parameter indexes from the method's definition. By having + // these indexes, we can simply then grab the exact intercepted + // parameters from the array of intercepted method parameters. + final ParameterIndexes parameterIndexes = this.getParameterIndexes(ctx); + + // When the method doesn't have a "SecurityContext" parameter we cannot + // perform the authorization checks, and therefore we cannot continue. + if (parameterIndexes.getSecurityContextIndex().isEmpty()) { + throw new IllegalStateException(String.format("The security context is not set on the method \"%s\", which is needed for the \"KesselRequiredPermission\" annotation to work", ctx.getMethod().getName())); + } + + // Grab the intercepted parameters and make sure that the security + // context is present, as otherwise we cannot perform any authorization + // checks. + final Object[] interceptedParameters = ctx.getParameters(); + + // Since we grabbed the parameter index for the "SecurityContext" + // parameter, we can safely cast that parameter to that so that we can + // use it. + final SecurityContext securityContext = (SecurityContext) interceptedParameters[parameterIndexes.getSecurityContextIndex().get()]; + + // Perform the legacy RBAC check first. The only reason is to be able + // to return early and make the code easier to follow. + if (!this.backendConfig.isKesselRelationsEnabled(SecurityContextUtil.getOrgId(securityContext))) { + // Legacy RBAC permission checking. The permission will have been + // prefetched and processed by the "ConsoleIdentityProvider". + if (securityContext.isUserInRole(annotation.legacyRBACRole())) { + return ctx.proceed(); + } else { + throw new ForbiddenException(); + } + } + + // When the execution reaches this point we can be sure that the + // enabled authorization back end is "Kessel", so we proceed to get the + // Kessel permissions. + final IntegrationPermission[] integrationPermissions = annotation.integrationPermissions(); + final WorkspacePermission[] workspacePermissions = annotation.workspacePermissions(); + + // Make sure that we have either integration permissions or workspace + // permissions to check. If we don't, that probably signals a mistake + // on the developer's side. + if (integrationPermissions.length == 0 && workspacePermissions.length == 0) { + throw new IllegalStateException(String.format("No integration or workspace permissions were set for method \"%s\", and at least one of them is required for the \"KesselRequiredPermission\" annotation to work", ctx.getMethod().getName())); + } + + // Check the workspace permissions first since they are more generic. + for (final WorkspacePermission workspacePermission : workspacePermissions) { + final UUID workspaceId = this.workspaceUtils.getDefaultWorkspaceId(SecurityContextUtil.getOrgId(securityContext)); + + this.kesselAuthorization.hasPermissionOnWorkspace(securityContext, workspacePermission, workspaceId); + } + + // If no integration permissions are specified we can simply skip any + // further checks. + if (integrationPermissions.length == 0) { + return ctx.proceed(); + } + + // We need to make sure that we spotted the integration's identifier in + // the method, as otherwise we will not be able to perform the checks. + if (parameterIndexes.getIntegrationIdIndex().isEmpty()) { + throw new IllegalStateException(String.format("The integration ID is not annotated on the method \"%s\", which is needed for the \"KesselRequiredPermission\" annotation to work", ctx.getMethod().getName())); + } + + // Now it is safe to grab the intercepted integration id... + final UUID integrationId = (UUID) interceptedParameters[parameterIndexes.getIntegrationIdIndex().get()]; + // ... and check the principal's permission. + for (final IntegrationPermission integrationPermission : integrationPermissions) { + this.kesselAuthorization.hasPermissionOnIntegration(securityContext, integrationPermission, integrationId); + } + + return ctx.proceed(); + } + + /** + * Grabs the indexes for the parameters we are interested in. + * @param ctx the method's invocation context. + * @return a {@link ParameterIndexes} object containing the relevant + * indexes we are interested in. + */ + private ParameterIndexes getParameterIndexes(final InvocationContext ctx) { + final ParameterIndexes parameterIndexes = new ParameterIndexes(); + + final Parameter[] methodParameters = ctx.getMethod().getParameters(); + for (int i = 0; i < methodParameters.length; i++) { + final Parameter parameter = methodParameters[i]; + + if (parameter.getType().equals(SecurityContext.class)) { + parameterIndexes.setSecurityContextIndex(i); + continue; + } + + if (parameter.getAnnotation(IntegrationId.class) != null) { + parameterIndexes.setIntegrationIdIndex(i); + } + } + return parameterIndexes; + } +} diff --git a/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/IntegrationId.java b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/IntegrationId.java new file mode 100644 index 0000000000..c1b65a624f --- /dev/null +++ b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/IntegrationId.java @@ -0,0 +1,15 @@ +package com.redhat.cloud.notifications.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An annotation that is simply used to tell the {@link AuthorizationInterceptor} + * which parameter contains the received integration identifier. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER}) +public @interface IntegrationId { +} diff --git a/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/ParameterIndexes.java b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/ParameterIndexes.java new file mode 100644 index 0000000000..e36b05bdf4 --- /dev/null +++ b/backend/src/main/java/com/redhat/cloud/notifications/auth/annotation/ParameterIndexes.java @@ -0,0 +1,30 @@ +package com.redhat.cloud.notifications.auth.annotation; + +import java.util.Optional; + +/** + * Holds the required parameter indexes to be able to perform the proper + * authorizations in the {@link AuthorizationInterceptor}. + */ +class ParameterIndexes { + private Integer securityContextIndex; + private Integer integrationIdIndex; + + ParameterIndexes() { } + + public Optional getSecurityContextIndex() { + return Optional.ofNullable(this.securityContextIndex); + } + + public void setSecurityContextIndex(final Integer securityContextIndex) { + this.securityContextIndex = securityContextIndex; + } + + public Optional getIntegrationIdIndex() { + return Optional.ofNullable(this.integrationIdIndex); + } + + public void setIntegrationIdIndex(final Integer integrationIdIndex) { + this.integrationIdIndex = integrationIdIndex; + } +} diff --git a/backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorHelper.java b/backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorHelper.java new file mode 100644 index 0000000000..bf4030402d --- /dev/null +++ b/backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorHelper.java @@ -0,0 +1,42 @@ +package com.redhat.cloud.notifications.auth.annotation; + +import com.redhat.cloud.notifications.auth.kessel.permission.IntegrationPermission; +import com.redhat.cloud.notifications.auth.kessel.permission.WorkspacePermission; +import jakarta.ws.rs.core.SecurityContext; + +import java.util.UUID; + +/** + * A simple helper class which defines a few methods for the {@link AuthorizationInterceptorTest} + * class. For some reason, defining them there made the method's annotations + * disappear when trying to use reflection to get them. + */ +public class AuthorizationInterceptorHelper { + + @Authorization(legacyRBACRole = AuthorizationInterceptorTest.LEGACY_RBAC_ROLE) + public void testMethodWithoutSecurityContext(final String ignored, final UUID ignoredTwo) { } + + @Authorization(legacyRBACRole = AuthorizationInterceptorTest.LEGACY_RBAC_ROLE) + public void testMethodWithRBACRole(final SecurityContext ignored, final String ignoredTwo, final UUID ignoredThree) { } + + @Authorization( + legacyRBACRole = AuthorizationInterceptorTest.LEGACY_RBAC_ROLE, + workspacePermissions = {WorkspacePermission.BUNDLES_VIEW, WorkspacePermission.APPLICATIONS_VIEW, WorkspacePermission.EVENT_TYPES_VIEW} + ) + public void testMethodWithWorkspacePermissions(final SecurityContext ignored, final String ignoredTwo, final UUID ignoredThree) { } + + @Authorization( + legacyRBACRole = AuthorizationInterceptorTest.LEGACY_RBAC_ROLE, + workspacePermissions = {WorkspacePermission.BUNDLES_VIEW, WorkspacePermission.APPLICATIONS_VIEW, WorkspacePermission.EVENT_TYPES_VIEW}, + integrationPermissions = {IntegrationPermission.VIEW, IntegrationPermission.VIEW_HISTORY} + ) + public void testMethodWithWorkspaceAndIntegrationPermissionsMissingIntegrationId(final SecurityContext ignored, final String ignoredTwo, final UUID ignoredThree) { } + + @Authorization( + legacyRBACRole = AuthorizationInterceptorTest.LEGACY_RBAC_ROLE, + workspacePermissions = {WorkspacePermission.BUNDLES_VIEW, WorkspacePermission.APPLICATIONS_VIEW, WorkspacePermission.EVENT_TYPES_VIEW}, + integrationPermissions = {IntegrationPermission.VIEW, IntegrationPermission.VIEW_HISTORY} + ) + public void testMethodWithWorkspaceAndIntegrationPermissions(final SecurityContext ignored, final String ignoredTwo, @IntegrationId final UUID ignoredThree) { } + +} diff --git a/backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorTest.java b/backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorTest.java new file mode 100644 index 0000000000..db83ad2183 --- /dev/null +++ b/backend/src/test/java/com/redhat/cloud/notifications/auth/annotation/AuthorizationInterceptorTest.java @@ -0,0 +1,453 @@ +package com.redhat.cloud.notifications.auth.annotation; + +import com.redhat.cloud.notifications.auth.kessel.KesselAuthorization; +import com.redhat.cloud.notifications.auth.kessel.KesselTestHelper; +import com.redhat.cloud.notifications.auth.kessel.ResourceType; +import com.redhat.cloud.notifications.auth.kessel.permission.IntegrationPermission; +import com.redhat.cloud.notifications.auth.kessel.permission.WorkspacePermission; +import com.redhat.cloud.notifications.auth.principal.ConsolePrincipal; +import com.redhat.cloud.notifications.auth.principal.rhid.RhIdPrincipal; +import com.redhat.cloud.notifications.auth.principal.rhid.RhIdentity; +import com.redhat.cloud.notifications.auth.rbac.workspace.WorkspaceUtils; +import com.redhat.cloud.notifications.config.BackendConfig; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import jakarta.interceptor.InvocationContext; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.SecurityContext; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.project_kessel.api.relations.v1beta1.CheckResponse; +import org.project_kessel.relations.client.CheckClient; + +import java.lang.reflect.Method; +import java.util.UUID; + +import static com.redhat.cloud.notifications.TestConstants.DEFAULT_ORG_ID; +import static com.redhat.cloud.notifications.TestConstants.DEFAULT_USER; + +@QuarkusTest +public class AuthorizationInterceptorTest { + @Inject + KesselTestHelper kesselTestHelper; + + @Inject + KesselAuthorization kesselAuthorization; + + @InjectMock + BackendConfig backendConfig; + + /** + * Required for the {@link KesselTestHelper}. + */ + @InjectMock + CheckClient checkClient; + + @InjectMock + WorkspaceUtils workspaceUtils; + + public static final String LEGACY_RBAC_ROLE = "legacy-rbac-role"; + + private AuthorizationInterceptor authorizationInterceptor; + + /** + * Mocks the security context with a {@link RhIdentity} as the principal. + * @return the mocked security context. + */ + private SecurityContext mockSecurityContext() { + return this.mockSecurityContext(null); + } + + /** + * Mocks the security context with a {@link RhIdentity} as the principal. + * @param authorizedRbacRole the RBAC role the principal will be authorized + * for. When the parameter is {@code null}, then + * the principal will not be authorized. + * @return the mocked security context. + */ + private SecurityContext mockSecurityContext(final String authorizedRbacRole) { + // Mock the security context. + final SecurityContext mockedSecurityContext = Mockito.mock(SecurityContext.class); + + // Authorize the user on the given RBAC role, if any. + Mockito.when(mockedSecurityContext.isUserInRole(authorizedRbacRole)).thenReturn(authorizedRbacRole != null && !authorizedRbacRole.isBlank()); + + // Create a RhIdentity principal and assign it to the mocked security + // context. + final RhIdentity identity = Mockito.mock(RhIdentity.class); + Mockito.when(identity.getName()).thenReturn("Red Hat user"); + Mockito.when(identity.getOrgId()).thenReturn(DEFAULT_ORG_ID); + Mockito.when(identity.getUserId()).thenReturn(DEFAULT_USER); + + final ConsolePrincipal principal = new RhIdPrincipal(identity); + Mockito.when(mockedSecurityContext.getUserPrincipal()).thenReturn(principal); + + return mockedSecurityContext; + } + + /** + * Manually set up the authorization interceptor, as using CDI injections + * does not work. + */ + @BeforeEach + public void setUp() { + this.authorizationInterceptor = new AuthorizationInterceptor(this.backendConfig, this.kesselAuthorization, this.workspaceUtils); + } + + /** + * Tests that when the method does not have a {@link jakarta.ws.rs.core.SecurityContext} + * parameter, then the function under test throws an exception. + * + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testMissingSecurityContextThrowsException() throws NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithoutSecurityContext", String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Call the function under test which should throw an exception that + // signals that the security context is required in the annotated + // method's signature. + final IllegalStateException exception = Assertions.assertThrows( + IllegalStateException.class, + () -> this.authorizationInterceptor.aroundInvoke(invocationContext) + ); + + // Assert that the expected exception was thrown. + Assertions.assertEquals( + String.format("The security context is not set on the method \"%s\", which is needed for the \"KesselRequiredPermission\" annotation to work", methodUnderTest.getName()), + exception.getMessage() + ); + } + + /** + * Tests that the function under test does not throw an exception when the + * principal has the expected legacy RBAC role. + * + * @throws Exception when an unexpected error occurs. + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testRBACPermissionGranted() throws Exception, NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithRBACRole", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context we want to the interceptor. + final SecurityContext securityContext = this.mockSecurityContext(LEGACY_RBAC_ROLE); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext}); + + // Call the function under test which should not throw any exceptions. + this.authorizationInterceptor.aroundInvoke(invocationContext); + } + + /** + * Tests that the function under test throws a {@link ForbiddenException} + * when the principal does hot havethe expected legacy RBAC role. + * + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testRBACPermissionDenied() throws NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithRBACRole", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is disabled for this test. + this.kesselTestHelper.mockKesselRelations(false); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context we want to the interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext}); + + // Call the function under test which should throw a ForbiddenException. + Assertions.assertThrows( + ForbiddenException.class, + () -> this.authorizationInterceptor.aroundInvoke(invocationContext) + ); + } + + /** + * Tests that the function under test throws an exception when Kessel is + * enabled as the authorization back end, and no {@link IntegrationPermission} + * or {@link WorkspacePermission} elements were defined in the annotation. + * + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testMissingIntegrationWorkspacePermissions() throws NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithRBACRole", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is enabled for this test. + this.kesselTestHelper.mockKesselRelations(true); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context we want to the interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext}); + + // Call the function under test which should throw an exception that + // signals that the required permissions are not defined in the + // annotation. + final IllegalStateException exception = Assertions.assertThrows( + IllegalStateException.class, + () -> this.authorizationInterceptor.aroundInvoke(invocationContext) + ); + + // Assert that the expected exception was thrown. + Assertions.assertEquals( + String.format("No integration or workspace permissions were set for method \"%s\", and at least one of them is required for the \"KesselRequiredPermission\" annotation to work", methodUnderTest.getName()), + exception.getMessage() + ); + } + + /** + * Tests that the function under test does not throw an exception and runs + * normally when the principal is authorized for the specified workspace + * permissions in the annotation. + * + * @throws Exception when an unexpected error occurs. + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testWorkspacePermissionGranted() throws Exception, NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithWorkspacePermissions", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is enabled for this test. + this.kesselTestHelper.mockKesselRelations(true); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context we want to the interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext}); + + // Mock the Kessel checks to simulate that the principal has the + // required workspace permissions. + this.kesselTestHelper.mockDefaultWorkspaceId(DEFAULT_ORG_ID); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.BUNDLES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.APPLICATIONS_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.EVENT_TYPES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + + // Call the function under test which should not throw any exceptions. + this.authorizationInterceptor.aroundInvoke(invocationContext); + } + + /** + * Tests that the function under test does not throw an exception and runs + * normally when the principal is authorized for the specified workspace + * permissions in the annotation. + * + * @throws Exception when an unexpected error occurs. + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testWorkspacePermissionDenied() throws Exception, NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithWorkspacePermissions", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is enabled for this test. + this.kesselTestHelper.mockKesselRelations(true); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context we want to the interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext}); + + // Mock the Kessel checks to simulate that the principal has the + // required workspace permissions. + this.kesselTestHelper.mockDefaultWorkspaceId(DEFAULT_ORG_ID); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.BUNDLES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.APPLICATIONS_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString(), CheckResponse.Allowed.ALLOWED_FALSE); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.EVENT_TYPES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + + // Call the function under test which should throw a ForbiddenException. + Assertions.assertThrows( + ForbiddenException.class, + () -> this.authorizationInterceptor.aroundInvoke(invocationContext) + ); + } + + /** + * Tests that the function under test throws an exception when an + * {@link IntegrationPermission} is specified, but the {@link IntegrationId} + * annotation has not been set in the method. + * + * @throws Exception when an unexpected error occurs. + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testMissingIntegrationIdAnnotation() throws Exception, NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithWorkspaceAndIntegrationPermissionsMissingIntegrationId", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is enabled for this test. + this.kesselTestHelper.mockKesselRelations(true); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context and integration ID we want to the + // interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + final String secondIgnoredParameter = "second-ignored-parameter"; + final UUID integrationId = UUID.randomUUID(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext, secondIgnoredParameter, integrationId}); + + // Mock the Kessel checks to simulate that the principal has the + // required workspace permissions. + this.kesselTestHelper.mockDefaultWorkspaceId(DEFAULT_ORG_ID); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.BUNDLES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.APPLICATIONS_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.EVENT_TYPES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + + // Mock the Kessel checks to simulate that the principal has the + // required integration permissions. + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, IntegrationPermission.VIEW, ResourceType.INTEGRATION, integrationId.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, IntegrationPermission.VIEW_HISTORY, ResourceType.INTEGRATION, integrationId.toString()); + + // Call the function under test which should throw an exception that + // signals that the method is missing a parameter annotated with the + // "IntegrationId" annotation. + final IllegalStateException exception = Assertions.assertThrows( + IllegalStateException.class, + () -> this.authorizationInterceptor.aroundInvoke(invocationContext) + ); + + // Assert that the expected exception was thrown. + Assertions.assertEquals( + String.format("The integration ID is not annotated on the method \"%s\", which is needed for the \"KesselRequiredPermission\" annotation to work", methodUnderTest.getName()), + exception.getMessage() + ); + } + + /** + * Tests that the function under test throws a {@link jakarta.ws.rs.NotFoundException} + * when the principal does not have authorization with one of the + * {@link IntegrationPermission}s. + * + * @throws Exception when an unexpected error occurs. + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testIntegrationPermissionDenied() throws Exception, NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithWorkspaceAndIntegrationPermissions", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is enabled for this test. + this.kesselTestHelper.mockKesselRelations(true); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context and integration ID we want to the + // interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + final String secondIgnoredParameter = "second-ignored-parameter"; + final UUID integrationId = UUID.randomUUID(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext, secondIgnoredParameter, integrationId}); + + // Mock the Kessel checks to simulate that the principal has the + // required workspace permissions. + this.kesselTestHelper.mockDefaultWorkspaceId(DEFAULT_ORG_ID); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.BUNDLES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.APPLICATIONS_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.EVENT_TYPES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + + // Mock the Kessel checks to simulate that the principal does not have + // all the required integration permissions. + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, IntegrationPermission.VIEW, ResourceType.INTEGRATION, integrationId.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, IntegrationPermission.VIEW_HISTORY, ResourceType.INTEGRATION, integrationId.toString(), CheckResponse.Allowed.ALLOWED_FALSE); + + // Call the function under test which should throw a ForbiddenException. + Assertions.assertThrows( + NotFoundException.class, + () -> this.authorizationInterceptor.aroundInvoke(invocationContext) + ); + } + + /** + * Tests that the function under test does not throw an exception when + * the principal has the specified {@link WorkspacePermission} and {@link IntegrationPermission}. + * + * @throws Exception when an unexpected error occurs. + * @throws NoSuchMethodException when the method's specification is not + * properly defined, and therefore we cannot get it to perform the tests. + */ + @Test + void testIntegrationPermissionGranted() throws Exception, NoSuchMethodException { + // Mock the invocation context and make it return one of our defined + // methods in this class, for simplicity. + final InvocationContext invocationContext = Mockito.mock(InvocationContext.class); + + final Method methodUnderTest = AuthorizationInterceptorHelper.class.getMethod("testMethodWithWorkspaceAndIntegrationPermissions", SecurityContext.class, String.class, UUID.class); + Mockito.when(invocationContext.getMethod()).thenReturn(methodUnderTest); + + // Make sure that Kessel relations is enabled for this test. + this.kesselTestHelper.mockKesselRelations(true); + + // Mock the returned parameters by the context, to be able to provide + // whichever security context and integration ID we want to the + // interceptor. + final SecurityContext securityContext = this.mockSecurityContext(); + final String secondIgnoredParameter = "second-ignored-parameter"; + final UUID integrationId = UUID.randomUUID(); + Mockito.when(invocationContext.getParameters()).thenReturn(new Object[]{securityContext, secondIgnoredParameter, integrationId}); + + // Mock the Kessel checks to simulate that the principal has the + // required workspace permissions. + this.kesselTestHelper.mockDefaultWorkspaceId(DEFAULT_ORG_ID); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.BUNDLES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.APPLICATIONS_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, WorkspacePermission.EVENT_TYPES_VIEW, ResourceType.WORKSPACE, KesselTestHelper.RBAC_DEFAULT_WORKSPACE_ID.toString()); + + // Mock the Kessel checks to simulate that the principal has the + // required integration permissions. + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, IntegrationPermission.VIEW, ResourceType.INTEGRATION, integrationId.toString()); + this.kesselTestHelper.mockKesselPermission(DEFAULT_USER, IntegrationPermission.VIEW_HISTORY, ResourceType.INTEGRATION, integrationId.toString()); + + // Call the function under test which should not throw any exceptions. + this.authorizationInterceptor.aroundInvoke(invocationContext); + } +} diff --git a/backend/src/test/java/com/redhat/cloud/notifications/auth/kessel/KesselTestHelper.java b/backend/src/test/java/com/redhat/cloud/notifications/auth/kessel/KesselTestHelper.java index 73a9e98ca3..307ff4f5f9 100644 --- a/backend/src/test/java/com/redhat/cloud/notifications/auth/kessel/KesselTestHelper.java +++ b/backend/src/test/java/com/redhat/cloud/notifications/auth/kessel/KesselTestHelper.java @@ -50,6 +50,12 @@ public class KesselTestHelper { */ public static final UUID RBAC_DEFAULT_WORKSPACE_ID = UUID.randomUUID(); + /** + * Defines a default Kessel domain that will get returned every time + * that the {@link BackendConfig#getKesselDomain()} method is called. + */ + public static final String DEFAULT_KESSEL_DOMAIN = "redhat"; + /** * Mocks the {@link LookupClient} so that it simulates that no authorized * integrations were fetched from Kessel. @@ -199,6 +205,11 @@ public void mockKesselRelations(final boolean isKesselRelationsEnabled) { return; } + // Return a default domain for Kessel. + Mockito + .when(this.backendConfig.getKesselDomain()) + .thenReturn(DEFAULT_KESSEL_DOMAIN); + // Default to an unauthorized response. Mockito .when(this.checkClient.check(Mockito.any()))