5

I'm developing application using Spring Boot, and I'm using Swagger to auto-generate API docs and also I use swagger-ui.html to interact with those APIs.

I have Spring Security enabled too, and I have Users with different roles. Different REST APIs are available to different roles.

Question: how do I configure Swagger to respect Spring's @Secured annotation and trim operations displayed by swagger-ui.html so that only operations available to current user are available?

I.e. imagine following controller

@RestController
@Secured(ROLE_USER)
public void SomeRestController {
  @GetMapping
  @Secured(ROLE_USER_TOP_MANAGER)
  public String getInfoForTopManager() { /*...*/ }

  @GetMapping
  @Secured(ROLE_USER_MIDDLE_MANAGER)
  public String getInfoForMiddleManager() { /*...*/ }

  @GetMapping
  public String getInfoForAnyUser() { /*...*/ }
}

Swagger will show both operations getInfoForTopManager and getInfoForMiddleManager regardless of current user role. In case currently authenticated user role is ROLE_USER_MIDDLE_MANAGER, I want only getInfoForMiddleManager and getInfoForAnyUser operations to be available in the Swagger.

Sergey Karpushin
  • 874
  • 1
  • 10
  • 33

1 Answers1

3

Ok, I think found good solution to that question. Solution consists of 2 parts:

  1. Extend controllers scanning logic through OperationBuilderPlugin to retain roles in the Swagger's vendor extensions
  2. Override ServiceModelToSwagger2MapperImpl bean to filter out actions based on current security context

In your project this might look a bit different (i.e. most likely you don't have thing like securityContextResolver), but I believe you'll get the gist of this solution from following code:

Part 1: Extend controllers scanning logic to retain roles in the Swagger's vendor extensions

@Component
@Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER + 1000)
public class OperationBuilderPluginSecuredAware implements OperationBuilderPlugin {
    @Override
    public void apply(OperationContext context) {
        Set<String> roles = new HashSet<>();
        Secured controllerAnnotation = context.findControllerAnnotation(Secured.class).orNull();
        if (controllerAnnotation != null) {
            roles.addAll(List.of(controllerAnnotation.value()));
        }

        Secured methodAnnotation = context.findAnnotation(Secured.class).orNull();
        if (methodAnnotation != null) {
            roles.addAll(List.of(methodAnnotation.value()));
        }

        if (!roles.isEmpty()) {
            context.operationBuilder().extensions(List.of(new TrimToRoles(roles.toArray(new String[0]))));
        }
    }

    @Override
    public boolean supports(DocumentationType delimiter) {
        return SwaggerPluginSupport.pluginDoesApply(delimiter);
    }
}

Part 2: Filter out actions based on current security context

@Primary
@Component
public class ServiceModelToSwagger2MapperImplEx extends ServiceModelToSwagger2MapperImpl {
    @Autowired
    private SecurityContextResolver<User> securityContextResolver;

    @Override
    protected io.swagger.models.Operation mapOperation(Operation from) {
        if (from == null) {
            return null;
        }
        if (!isPermittedForCurrentUser(findTrimToRolesExtension(from.getVendorExtensions()))) {
            return null;
        }
        return super.mapOperation(from);
    }

    private boolean isPermittedForCurrentUser(TrimToRoles trimToRoles) {
        if (trimToRoles == null) {
            return true;
        }
        if (securityContextResolver.hasAnyRole(trimToRoles.getValue())) {
            return true;
        }
        return false;
    }

    private TrimToRoles findTrimToRolesExtension(@SuppressWarnings("rawtypes") List<VendorExtension> list) {
        if (CollectionUtils.isEmpty(list)) {
            return null;
        }
        return list.stream().filter(x -> x instanceof TrimToRoles).map(TrimToRoles.class::cast).findFirst()
                .orElse(null);
    }

    @Override
    protected Map<String, Path> mapApiListings(Multimap<String, ApiListing> apiListings) {
        Map<String, Path> paths = super.mapApiListings(apiListings);
        return paths.entrySet().stream().filter(x -> !x.getValue().isEmpty())
                .collect(Collectors.toMap(x -> x.getKey(), v -> v.getValue()));
    }

    @Override
    public Swagger mapDocumentation(Documentation from) {
        Swagger ret = super.mapDocumentation(from);
        Predicate<? super Tag> hasAtLeastOneOperation = tag -> ret.getPaths().values().stream()
                .anyMatch(x -> x.getOperations().stream().anyMatch(y -> y.getTags().contains(tag.getName())));
        ret.setTags(ret.getTags().stream().filter(hasAtLeastOneOperation).collect(Collectors.toList()));
        return ret;
    }
}

p.s. these impls are not efficient, but given their usage scenarios I preferred simple impl

Sergey Karpushin
  • 874
  • 1
  • 10
  • 33