Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/development/extensions-core/druid-pac4j.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,18 @@ druid.auth.authenticator.jwt.type=jwt
|`druid.auth.pac4j.oidc.scope`| scope is used by an application during authentication to authorize access to a user's details.|`openid profile email`|No|
|`druid.auth.pac4j.oidc.clientAuthenticationMethod`|The client authentication method to use when communicating with the OIDC provider. Supported values: `client_secret_basic`, `client_secret_post`, `client_secret_jwt`, `private_key_jwt`, `none`. If not specified, pac4j will auto-detect the method from the provider's discovery document. Set this explicitly if you need to use a specific method (e.g., when your provider advertises multiple methods but you want to use a particular one).|Auto-detected from provider|No|

### Role claim mapping

The `druid.auth.pac4j.oidc.roleClaimPath` property applies only to the browser-based `pac4j` authenticator. When configured, Druid extracts roles from the OIDC profile or ID token claims returned during the pac4j login flow. If no matching roles are found there, Druid falls back to the OIDC access token.

The `jwt` authenticator does not use `roleClaimPath`. It validates the bearer ID token and uses `druid.auth.pac4j.oidc.oidcClaim` as the Druid identity.

|Property|Description|Default|required|
|--------|---------------|-----------|-------|
|`druid.auth.pac4j.oidc.roleClaimPath`|Dot-separated path to the claim containing user roles.|none|No|

:::info
Users must set a strong passphrase to ensure that an attacker is not able to guess it simply by brute force.
A compromised passphrase may allow an attacker to read and manipulate session cookies.
For more details, see [CVE-2024-45384](https://nvd.nist.gov/vuln/detail/CVE-2024-45384).
:::
:::
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,12 @@ public Set<String> getRoles(String authorizerPrefix, AuthenticationResult authen
}
}

// Get the roles assigned to LDAP user from the metastore.
// This allow us to authorize LDAP users regardless of whether they belong to any groups or not in LDAP.
BasicAuthorizerUser user = userMap.get(authenticationResult.getIdentity());
if (user != null) {
roleNames.addAll(user.getRoles());
}
roleNames.addAll(RoleProviderUtil.getUserRoles(
userMap,
authorizerPrefix,
authenticationResult,
cacheManager
));

return roleNames;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,18 @@ public MetadataStoreRoleProvider(
@Override
public Set<String> getRoles(String authorizerPrefix, AuthenticationResult authenticationResult)
{
Set<String> roleNames = new HashSet<>();

Map<String, BasicAuthorizerUser> userMap = cacheManager.getUserMap(authorizerPrefix);
if (userMap == null) {
throw new IAE("Could not load userMap for authorizer [%s]", authorizerPrefix);
}

BasicAuthorizerUser user = userMap.get(authenticationResult.getIdentity());
if (user != null) {
roleNames.addAll(user.getRoles());
}
return roleNames;
return new HashSet<>(RoleProviderUtil.getUserRoles(
userMap,
authorizerPrefix,
authenticationResult,
cacheManager
));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.apache.druid.security.basic.authorization;

import com.google.common.annotations.VisibleForTesting;
import org.apache.druid.java.util.common.logger.Logger;
import org.apache.druid.security.basic.authorization.db.cache.BasicAuthorizerCacheManager;
import org.apache.druid.security.basic.authorization.entity.BasicAuthorizerRole;
import org.apache.druid.security.basic.authorization.entity.BasicAuthorizerUser;
import org.apache.druid.server.security.AuthenticationResult;

import javax.annotation.Nullable;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class RoleProviderUtil
{
private static final Logger LOG = new Logger(RoleProviderUtil.class);
public static final String ROLE_CLAIM_CONTEXT_KEY = "druidRoles";

public static Set<String> getUserRoles(
Map<String, BasicAuthorizerUser> userMap,
String authorizerPrefix,
AuthenticationResult authenticationResult,
BasicAuthorizerCacheManager cacheManager
)
{
Set<String> claims = RoleProviderUtil.claimValuesFromCtx(authenticationResult.getContext());

if (claims != null) {
return getRolesByClaimValue(
authorizerPrefix,
claims,
cacheManager
);
} else {
return getRolesByIdentity(
userMap,
authenticationResult.getIdentity()
);
}
}

@VisibleForTesting
public static Set<String> getRolesByIdentity(
Map<String, BasicAuthorizerUser> userMap,
String identity
)
{
Set<String> roles = new HashSet<>();

BasicAuthorizerUser user = userMap.get(identity);
if (user != null) {
roles.addAll(user.getRoles());
}
return roles;
}

@VisibleForTesting
public static Set<String> getRolesByClaimValue(
String authorizerPrefix,
Set<String> claimValue,
BasicAuthorizerCacheManager cacheManager
)
{
Map<String, BasicAuthorizerRole> roleMap = cacheManager.getRoleMap(authorizerPrefix);

if (roleMap == null) {
return Set.of();
}

final Set<String> matched = roleMap.keySet()
.stream()
.filter(claimValue::contains)
.collect(Collectors.toUnmodifiableSet());

if (matched.isEmpty()) {
LOG.warn("Role claim present but no values mapped to Druid roles");
}

return matched;
}

@Nullable
protected static Set<String> claimValuesFromCtx(Map<String, Object> ctx)
{
Object value = (ctx == null) ? null : ctx.get(RoleProviderUtil.ROLE_CLAIM_CONTEXT_KEY);
if (!(value instanceof Set)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a role claim set i.e. values is not null but we don't find expected type here then instead of returning null and thus not enforcing any role should we throw error?

Copy link
Copy Markdown
Contributor Author

@nozjkoitop nozjkoitop Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Id not throw here, cuz it's more of a misconfiguration than an exceptional case, however i can add warnings on such cases to pinpoint the issue

if (value != null) {
LOG.warn(
"Role claim context key [%s] is present but expected Set, got [%s]",
RoleProviderUtil.ROLE_CLAIM_CONTEXT_KEY,
value.getClass().getName()
);
}
return null;
}
Set<?> rawClaimValues = (Set<?>) value;

Set<String> result = new HashSet<>();
for (Object claimValue : rawClaimValues) {
if (!(claimValue instanceof String)) {
LOG.warn(
"Role claim context key [%s] contains a non-String value of type [%s]",
RoleProviderUtil.ROLE_CLAIM_CONTEXT_KEY,
claimValue == null ? "null" : claimValue.getClass().getName()
);
return null;
}
String str = ((String) claimValue).trim();
if (!str.isEmpty()) {
result.add(str);
}
}
return result.isEmpty() ? null : result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.apache.druid.security.authorization;

import org.apache.druid.security.basic.authorization.MetadataStoreRoleProvider;
import org.apache.druid.security.basic.authorization.RoleProviderUtil;
import org.apache.druid.security.basic.authorization.db.cache.BasicAuthorizerCacheManager;
import org.apache.druid.security.basic.authorization.entity.BasicAuthorizerRole;
import org.apache.druid.security.basic.authorization.entity.BasicAuthorizerUser;
import org.apache.druid.server.security.AuthenticationResult;
import org.junit.Test;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import static org.junit.Assert.assertEquals;

public class MetadataStoreRoleProviderGetRolesTest
{

@Test
public void returnsRolesByClaimValuesWhenPresent()
{
Map<String, BasicAuthorizerRole> roles = new HashMap<>();
roles.put("admin", null);
roles.put("viewer", null);

Set<String> viewerRole = Set.of("viewer");

BasicAuthorizerUser user = new BasicAuthorizerUser("alice", viewerRole);

Map<String, BasicAuthorizerUser> users = Map.of("alice", user);

BasicAuthorizerCacheManager cache = new StubCacheManager(users, roles);
MetadataStoreRoleProvider provider = new MetadataStoreRoleProvider(cache);

Set<String> claims = Set.of("admin", "extraneous");

Map<String, Object> ctx = Map.of(RoleProviderUtil.ROLE_CLAIM_CONTEXT_KEY, claims);

AuthenticationResult ar = new AuthenticationResult("alice", "basic", "pac4j", ctx);

Set<String> out = provider.getRoles("basic", ar);
Set<String> expected = Set.of("admin");
assertEquals(expected, out);
}

@Test
public void fallsBackToIdentityWhenNoClaimContext()
{
Set<String> viewerRole = Set.of("viewer");
BasicAuthorizerUser user = new BasicAuthorizerUser("alice", viewerRole);

Map<String, BasicAuthorizerUser> users = Map.of("alice", user);

Map<String, BasicAuthorizerRole> roles = new HashMap<>();
roles.put("admin", null);

BasicAuthorizerCacheManager cache = new StubCacheManager(users, roles);
MetadataStoreRoleProvider provider = new MetadataStoreRoleProvider(cache);

AuthenticationResult ar = new AuthenticationResult("alice", "basic", "pac4j", Collections.emptyMap());

Set<String> out = provider.getRoles("basic", ar);
Set<String> expected = Set.of("viewer");
assertEquals(expected, out);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.apache.druid.security.authorization;

import org.apache.druid.security.basic.authorization.RoleProviderUtil;
import org.apache.druid.security.basic.authorization.db.cache.BasicAuthorizerCacheManager;
import org.apache.druid.security.basic.authorization.entity.BasicAuthorizerRole;
import org.apache.druid.security.basic.authorization.entity.BasicAuthorizerUser;
import org.junit.Test;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;


public class RoleProviderUtilTest
{

@Test
public void getRolesByIdentityAddsRolesWhenUserFound()
{
Set<String> roles = Set.of("r1", "r2");
BasicAuthorizerUser user = new BasicAuthorizerUser("id", roles);

Map<String, BasicAuthorizerUser> userMap = Map.of("id", user);

Set<String> out = RoleProviderUtil.getRolesByIdentity(userMap, "id");
assertEquals(roles, out);
}

@Test
public void getRolesByIdentityNoopWhenUserMissing()
{
Map<String, BasicAuthorizerUser> userMap = Map.of();
Set<String> out = RoleProviderUtil.getRolesByIdentity(userMap, "missing");
assertTrue(out.isEmpty());
}

@Test
public void getRolesByClaimValuesFiltersByRoleNames()
{
Map<String, BasicAuthorizerRole> roles = new HashMap<>();
roles.put("r1", null);
roles.put("r2", null);

BasicAuthorizerCacheManager cache = new StubCacheManager(Map.of(), roles);

Set<String> claims = Set.of("r2", "nope");
Set<String> out = RoleProviderUtil.getRolesByClaimValue("authz", claims, cache);
assertEquals(Set.of("r2"), out);
}

@Test
public void getRolesByClaimValuesThrowsWhenRoleMapNull()
{
BasicAuthorizerCacheManager cache = new StubCacheManager(Map.of(), null);
assertTrue(RoleProviderUtil.getRolesByClaimValue("authz", Set.of("r2"), cache).isEmpty());
}
}
Loading
Loading