Skip to content

Commit

Permalink
feat(auth): externalizable source of truth for roles and attributes (#…
Browse files Browse the repository at this point in the history
…678)


Co-authored-by: Julien Chanaud <julien.chanaud@michelin.com>
  • Loading branch information
twobeeb and Julien Chanaud committed Jun 11, 2021
1 parent 5c4b5eb commit bba0cc5
Show file tree
Hide file tree
Showing 25 changed files with 997 additions and 482 deletions.
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,8 @@ Define groups with specific roles for your users
* `attributes.connects-filter-regexp`: Regexp list to filter Connect tasks available for current group
* `attributes.consumer-groups-filter-regexp`: Regexp list to filter Consumer Groups available for current group

:warning: `topics-filter-regexp`, `connects-filter-regexp` and `consumer-groups-filter-regexp` are only used when listing resources.
If you have `topics/create` or `connect/create` roles and you try to create a resource that doesn't follow the regexp, that resource **WILL** be created.

3 defaults group are available :
- `admin` with all right
Expand Down Expand Up @@ -612,6 +614,113 @@ akhq:

The username field can be any string field, the roles field has to be a JSON array.

### External roles and attributes mapping

If you managed which topics (or any other resource) in an external system, you have access to 2 more implementations mechanisms to map your authenticated user (from either Local, LDAP or OIDC Authent) into AKHQ roles and attributes:

If you use this mechanism, keep in mind it will take the local user's groups for local Auth, and the external groups for LDAP/OIDC (ie. this will NOT do the mapping between LDAP/OIDC and local groups)

**Default configuration-based**
This is the current implementation and the default one (doesn't break compatibility)
````yaml
akhq:
security:
default-group: no-roles
groups:
reader:
roles:
- topic/read
attributes:
topics-filter-regexp: [".*"]
no-roles:
roles: []
ldap: # LDAP users/groups to AKHQ groups mapping
oidc: # OIDC users/groups to AKHQ groups mapping
````

**REST API**
````yaml
akhq:
security:
default-group: no-roles
rest:
enabled: true
url: https://external.service/get-roles-and-attributes
groups: # anything set here will not be used
````

In this mode, AKHQ will send to the ``akhq.security.rest.url`` endpoint a POST request with the following JSON :

````json
{
"providerType": "LDAP or OIDC or BASIC_AUTH",
"providerName": "OIDC provider name (OIDC only)",
"username": "user",
"groups": ["LDAP-GROUP-1", "LDAP-GROUP-2", "LDAP-GROUP-3"]
}
````
and expect the following JSON as response :
````json
{
"roles": ["topic/read", "topic/write", "..."],
"attributes":
{
"topics-filter-regexp": [".*"],
"connects-filter-regexp": [".*"],
"consumer-groups-filter-regexp": [".*"]
}
}
````

**Groovy API**
````yaml
akhq:
security:
default-group: no-roles
groovy:
enabled: true
file: |
package org.akhq.utils;
class GroovyCustomClaimProvider implements ClaimProvider {
@Override
AKHQClaimResponse generateClaim(AKHQClaimRequest request) {
AKHQClaimResponse a = new AKHQClaimResponse();
a.roles = ["topic/read"]
a.attributes = [
topicsFilterRegexp: [".*"],
connectsFilterRegexp: [".*"],
consumerGroupsFilterRegexp: [".*"]
]
return a
}
}
groups: # anything set here will not be used
````
``akhq.security.groovy.file`` must be a groovy class that implements the interface ClaimProvider :
````java
package org.akhq.utils;
public interface ClaimProvider {

AKHQClaimResponse generateClaim(AKHQClaimRequest request);

class AKHQClaimRequest{
ProviderType providerType;
String providerName;
String username;
List<String> groups;
}
class AKHQClaimResponse {
private List<String> roles;
private Map<String,Object> attributes;
}
enum ProviderType {
BASIC_AUTH,
LDAP,
OIDC
}
}
````

### Debugging authentication

Debugging auth can be done by increasing log level on Micronaut that handle most of the authentication part :
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ dependencies {
// Password hashing
implementation group: "org.mindrot", name: "jbcrypt", version: "0.4"

// https://mvnrepository.com/artifact/org.codehaus.groovy/groovy-all
implementation group: 'org.codehaus.groovy', name: 'groovy-all', version: '3.0.7'
// api

// client
implementation project(":client")
Expand Down
8 changes: 4 additions & 4 deletions src/main/java/org/akhq/controllers/AbstractController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import io.micronaut.context.annotation.Value;
import io.micronaut.security.utils.SecurityService;
import org.akhq.configs.SecurityProperties;
import org.akhq.utils.UserGroupUtils;
import org.akhq.utils.DefaultGroupUtils;

import javax.inject.Inject;
import java.net.URI;
Expand All @@ -20,7 +20,7 @@ abstract public class AbstractController {
private ApplicationContext applicationContext;

@Inject
private UserGroupUtils userGroupUtils;
private DefaultGroupUtils defaultGroupUtils;

@Inject
private SecurityProperties securityProperties;
Expand Down Expand Up @@ -63,7 +63,7 @@ protected boolean isAllowed(String role) {
@SuppressWarnings("unchecked")
protected List<String> getRights() {
if (!applicationContext.containsBean(SecurityService.class)) {
return expandRoles(this.userGroupUtils.getUserRoles(Collections.singletonList(securityProperties.getDefaultGroup())));
return expandRoles(this.defaultGroupUtils.getDefaultRoles());
}

SecurityService securityService = applicationContext.getBean(SecurityService.class);
Expand All @@ -72,7 +72,7 @@ protected List<String> getRights() {
securityService
.getAuthentication()
.map(authentication -> (List<String>) authentication.getAttributes().get("roles"))
.orElseGet(() -> this.userGroupUtils.getUserRoles(Collections.singletonList(securityProperties.getDefaultGroup())))
.orElseGet(() -> this.defaultGroupUtils.getDefaultRoles())
);
}
}
52 changes: 31 additions & 21 deletions src/main/java/org/akhq/modules/BasicAuthAuthenticationProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,53 @@
import io.micronaut.security.authentication.*;
import io.reactivex.Flowable;
import org.akhq.configs.BasicAuth;
import org.akhq.configs.Ldap;
import org.akhq.configs.Oidc;
import org.akhq.configs.SecurityProperties;
import org.akhq.utils.UserGroupUtils;
import org.akhq.utils.ClaimProvider;
import org.reactivestreams.Publisher;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Optional;

@Singleton
public class BasicAuthAuthenticationProvider implements AuthenticationProvider {
@Inject
private SecurityProperties securityProperties;
@Inject
private Oidc oidc;
@Inject
private Ldap ldap;

@Inject
private UserGroupUtils userGroupUtils;
private ClaimProvider claimProvider;

@Override
public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest, AuthenticationRequest<?, ?> authenticationRequest) {
String username = String.valueOf(authenticationRequest.getIdentity());
for (BasicAuth auth : securityProperties.getBasicAuth()) {
if (!username.equals(auth.getUsername())) {
continue;
}
if (!auth.isValidPassword((String) authenticationRequest.getSecret())) {
return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH));
}
UserDetails userDetails = new UserDetails(username,
userGroupUtils.getUserRoles(auth.getGroups()),
userGroupUtils.getUserAttributes(auth.getGroups()));
return Flowable.just(userDetails);
Optional<BasicAuth> optionalBasicAuth = securityProperties.getBasicAuth()
.stream()
.filter(basicAuth -> basicAuth.getUsername().equals(username))
.findFirst();

// User not found
if(optionalBasicAuth.isEmpty()){
return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.USER_NOT_FOUND));
}
BasicAuth auth = optionalBasicAuth.get();

return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.USER_NOT_FOUND));
// Invalid password
if (!auth.isValidPassword((String) authenticationRequest.getSecret())) {
return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH));
}

ClaimProvider.AKHQClaimRequest request =
ClaimProvider.AKHQClaimRequest.builder()
.providerType(ClaimProvider.ProviderType.BASIC_AUTH)
.providerName(null)
.username(auth.getUsername())
.groups(auth.getGroups())
.build();
try {
ClaimProvider.AKHQClaimResponse claim = claimProvider.generateClaim(request);
return Flowable.just(new UserDetails(auth.getUsername(), claim.getRoles(), claim.getAttributes()));
} catch (Exception e) {
String claimProviderClass = claimProvider.getClass().getName();
return Flowable.just(new AuthenticationFailed("Exception from ClaimProvider " + claimProviderClass + ": " + e.getMessage()));
}
}
}
33 changes: 17 additions & 16 deletions src/main/java/org/akhq/modules/LdapContextAuthenticationMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import io.micronaut.configuration.security.ldap.DefaultContextAuthenticationMapper;
import io.micronaut.context.annotation.Replaces;
import io.micronaut.core.convert.value.ConvertibleValues;
import io.micronaut.security.authentication.AuthenticationFailed;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.UserDetails;
import org.akhq.configs.Ldap;
import org.akhq.utils.UserGroupUtils;
import org.akhq.utils.ClaimProvider;

import javax.inject.Inject;
import javax.inject.Singleton;
Expand All @@ -19,23 +19,24 @@
public class LdapContextAuthenticationMapper implements ContextAuthenticationMapper {

@Inject
private Ldap ldap;

@Inject
private UserGroupUtils userGroupUtils;
private ClaimProvider claimProvider;

@Override
public AuthenticationResponse map(ConvertibleValues<Object> attributes, String username, Set<String> groups) {
List<String> akhqGroups = getUserAkhqGroups(username, groups);
return new UserDetails(username, userGroupUtils.getUserRoles(akhqGroups), userGroupUtils.getUserAttributes(akhqGroups));
ClaimProvider.AKHQClaimRequest request = ClaimProvider.AKHQClaimRequest.builder()
.providerType(ClaimProvider.ProviderType.LDAP)
.providerName(null)
.username(username)
.groups(List.copyOf(groups))
.build();
try {
ClaimProvider.AKHQClaimResponse claim = claimProvider.generateClaim(request);
return new UserDetails(username, claim.getRoles(), claim.getAttributes());
} catch (Exception e) {
String claimProviderClass = claimProvider.getClass().getName();
return new AuthenticationFailed("Exception from ClaimProvider " + claimProviderClass + ": " + e.getMessage());
}
}

/**
* Get Akhq Groups configured in Ldap groups
* @param ldapGroups list of ldap groups associated to the user
* @return list of Akhq groups configured for the ldap groups. See in application.yml property akhq.security.ldap
*/
private List<String> getUserAkhqGroups(String username, Set<String> ldapGroups) {
return UserGroupUtils.mapToAkhqGroups(username, ldapGroups, ldap.getGroups(), ldap.getUsers(), ldap.getDefaultGroup());
}

}
Loading

0 comments on commit bba0cc5

Please sign in to comment.