Skip to content

Commit

Permalink
RHCLOUD-37536 | feature: authorization annotation for improved mainta…
Browse files Browse the repository at this point in the history
…inability (#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 <[email protected]>

---------

Co-authored-by: Guillaume Duval <[email protected]>
  • Loading branch information
MikelAlejoBR and g-duval authored Feb 18, 2025
1 parent f090ca4 commit ee7973e
Show file tree
Hide file tree
Showing 7 changed files with 748 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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 {};
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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<Integer> getSecurityContextIndex() {
return Optional.ofNullable(this.securityContextIndex);
}

public void setSecurityContextIndex(final Integer securityContextIndex) {
this.securityContextIndex = securityContextIndex;
}

public Optional<Integer> getIntegrationIdIndex() {
return Optional.ofNullable(this.integrationIdIndex);
}

public void setIntegrationIdIndex(final Integer integrationIdIndex) {
this.integrationIdIndex = integrationIdIndex;
}
}
Original file line number Diff line number Diff line change
@@ -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) { }

}
Loading

0 comments on commit ee7973e

Please sign in to comment.