Skip to content

Commit e16a608

Browse files
committed
added eligibility checks framework
1 parent 0242cfb commit e16a608

File tree

12 files changed

+338
-7
lines changed

12 files changed

+338
-7
lines changed

README.adoc

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ The content of the configuration file is as follows:
8989

9090
|load_ldap_service
9191
|defaults to false, if it is true, SPI mechanism will look on class path to load custom implementation of `LDAPUserRetriever`.
92+
93+
|eligibility_class_name
94+
|defaults to `NoOpLoginEligibilityCheck`
95+
96+
|eligibility_cassandra_keyspace
97+
|defaults to `login_eligibility`
98+
99+
|eligibility_cassandra_table
100+
|defaults to `login_eligibility`
101+
102+
|eligibility_cassandra_user_column
103+
|defaults to `user`
104+
105+
|eligibility_cassandra_access_column
106+
|defaults to `has_access`
92107
|===
93108

94109

@@ -158,6 +173,70 @@ may have a different search filter based on your need, a lot of people use e.g.
158173
If you try to log in with `cqlsh -u cn=myuserinldap`, there will be no replacement done and this will be
159174
used as a search filter instead.
160175

176+
## Login eligibility checks
177+
178+
There is an additional mechanims implemented for login eligibility resoultion after user is authenticated
179+
in LDAP. If user is authenticated against LDAP and he is not able to log in (e.g. submitted password is wrong),
180+
this check is bypassed. However, even if a user in LDAP is able to log in, Cassandra database administrators
181+
can use additional checks to decide if a user who passed LDAP authentication procedure is indeed able to log in or not.
182+
183+
The login eligibility check implementation is driven by the configuration property `eligibility_class_name` which
184+
defaults to the no-op implementation which means that login to LDAP will make such user eligible to log in to Cassandra
185+
without any further restrictions / checks.
186+
187+
There is a default Cassandra implementation provided which might be used by setting `eligibility_class_name` to
188+
`com.instaclustr.cassandra.ldap.auth.Cassandra311LoginEligibilityCheck` for C* 3.11. If you are running Cassandra 4.0, please
189+
set this property to `com.instaclustr.cassandra.ldap.auth.Cassandra40LoginEligibilityCheck`.
190+
IF you use 3.0 or 2.2, use `CassandraLoginEligibilityCheck`.
191+
192+
Before using this feature (when you are using Cassandra check), respective keyspace
193+
and table need to be created which will capture eligibility data. The default script
194+
is located in `conf/eligibility_check.cql`. Please keep in mind that you might
195+
alter this keyspace to e.g. reflect your replication strategy requirements - this duty is
196+
left to Cassandra operator.
197+
198+
Operator is responsible for the population of this table, plugin just reads from this table
199+
and it expects that such record is found. For example, let's say that operator inserted this record:
200+
201+
----
202+
cqlsh> INSERT INTO login_eligibility.login_eligibility (user , has_access ) VALUES ( 'cn=stefan,dc=example,dc=org', true) USING TTL 60;
203+
cqlsh> select user, has_access, ttl(has_access) from login_eligibility.login_eligibility where user = 'stefan';
204+
205+
user | has_access | ttl(has_access)
206+
-----------------------------+------------+-----------------
207+
cn=stefan,dc=example,dc=org | True | 55
208+
209+
(1 rows)
210+
----
211+
212+
The default Cassandra check implementation will do this check upon login:
213+
214+
----
215+
clqsh> select user, has_access from login_eligibility.login_eligibility where user = 'cn=stefan,dc=example,dc=org';
216+
----
217+
218+
Then the plugin looks into `has_access` and it has to be `true`.
219+
220+
TTL is optional, here it is used with advantage that a user is eligible to be logged in
221+
just for 1 minute. There might be e.g. some company policy to enable a user to log in
222+
during 2 days, for example, which would mimic this policy. After 2 days, such record
223+
is not present in database anymore which renders a user to be unable to log in.
224+
225+
If you want to use custom eligibility check implementation, you need to firstly implement the interface
226+
`com.instaclustr.cassandra.ldap.auth.LoginEligibilityCheck`, then you need to
227+
create a JAR with this class and put it on Cassandra's class path. In your JAR,
228+
you need to specify your implemenatation in `src/main/resources/META-INF/services` where you need
229+
to put a file with name of FQCN of interface and its content will be FQCN of your implementation which
230+
implements this interface.
231+
232+
If you do not want to use SPI mechanism mentioned above, you still have to put your
233+
implementation on the class path but you have to specify your implementation
234+
in `eligibility_class_name` configuration property.
235+
236+
If you want to use different configuration properties for your custom implementation, you have them
237+
available in interfaces' method `init` where they are passed from plugin internals upon
238+
its setup.
239+
161240
## How it Works
162241

163242
LDAPAuthenticator currently supports plain text authorization requests only in the form of a username and password.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.instaclustr.cassandra.ldap.auth;
2+
3+
import java.util.Properties;
4+
5+
import com.instaclustr.cassandra.ldap.User;
6+
import com.instaclustr.cassandra.ldap.conf.LdapAuthenticatorConfiguration;
7+
import org.apache.cassandra.serializers.BooleanSerializer;
8+
import org.apache.cassandra.service.ClientState;
9+
import org.apache.cassandra.transport.messages.ResultMessage;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
public abstract class BaseCassandraLoginEligibilityCheck implements LoginEligibilityCheck
14+
{
15+
private static final Logger logger = LoggerFactory.getLogger(BaseCassandraLoginEligibilityCheck.class);
16+
17+
private static final String BASE_SELECT_USER_STATEMENT_TEMPLATE = "select %s from %s.%s where %s = ?";
18+
19+
protected ClientState clientState;
20+
protected Properties configProperties;
21+
protected String selectStatement;
22+
23+
@Override
24+
public void init(final ClientState clientState, final Properties configProperties)
25+
{
26+
this.clientState = clientState;
27+
this.configProperties = configProperties;
28+
29+
this.selectStatement = String.format(BASE_SELECT_USER_STATEMENT_TEMPLATE,
30+
configProperties.getProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_ACCESS_COLUMN),
31+
configProperties.getProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_KEYSPACE),
32+
configProperties.getProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_TABLE),
33+
configProperties.getProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_USER_COLUMN));
34+
35+
}
36+
37+
protected abstract ResultMessage.Rows getRows(final String loginName);
38+
39+
@Override
40+
public boolean isEligibleToLogin(final User user, final String loginName)
41+
{
42+
43+
// all non-ldap users are free to log in just fine
44+
if (user.getLdapDN() == null)
45+
{
46+
return true;
47+
}
48+
49+
assert clientState != null;
50+
51+
final ResultMessage.Rows rows = getRows(loginName);
52+
53+
final boolean noResults = rows.result.isEmpty();
54+
55+
if (noResults)
56+
{
57+
logger.info(String.format("User with login name '%s' is not eligible to be logged in!", loginName));
58+
return false;
59+
}
60+
else
61+
{
62+
if (rows.result.size() != 1)
63+
{
64+
throw new IllegalStateException("There was more than one record returned from eligibility check select query!");
65+
}
66+
67+
if (rows.result.rows.get(0).size() != 1)
68+
{
69+
throw new IllegalStateException("There was more than one column returned from eligibility check select query!");
70+
}
71+
72+
if (BooleanSerializer.instance.deserialize(rows.result.rows.get(0).get(0)))
73+
{
74+
logger.info(String.format("User with login name '%s' is eligible to be logged in!", loginName));
75+
return true;
76+
}
77+
else
78+
{
79+
logger.info(String.format("User with login name '%s' is not eligible to be logged in!", loginName));
80+
return false;
81+
}
82+
}
83+
}
84+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.instaclustr.cassandra.ldap.auth;
2+
3+
import static java.util.Collections.singletonList;
4+
5+
import org.apache.cassandra.cql3.QueryOptions;
6+
import org.apache.cassandra.cql3.QueryProcessor;
7+
import org.apache.cassandra.cql3.statements.SelectStatement;
8+
import org.apache.cassandra.service.QueryState;
9+
import org.apache.cassandra.transport.messages.ResultMessage.Rows;
10+
import org.apache.cassandra.utils.ByteBufferUtil;
11+
12+
public class CassandraLoginEligibilityCheck extends BaseCassandraLoginEligibilityCheck
13+
{
14+
@Override
15+
protected Rows getRows(final String loginName)
16+
{
17+
final SelectStatement selStmt = (SelectStatement) QueryProcessor.getStatement(selectStatement, clientState).statement;
18+
return selStmt.execute(new QueryState(clientState), QueryOptions.forInternalCalls(singletonList(ByteBufferUtil.bytes(loginName))));
19+
}
20+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.instaclustr.cassandra.ldap.auth;
2+
3+
import java.util.Properties;
4+
5+
import com.instaclustr.cassandra.ldap.User;
6+
import org.apache.cassandra.service.ClientState;
7+
8+
public interface LoginEligibilityCheck
9+
{
10+
11+
void init(final ClientState clientState, final Properties configProperties);
12+
13+
boolean isEligibleToLogin(final User user, final String loginName);
14+
15+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.instaclustr.cassandra.ldap.auth;
2+
3+
import java.util.Properties;
4+
5+
import com.instaclustr.cassandra.ldap.User;
6+
import org.apache.cassandra.service.ClientState;
7+
8+
public final class NoOpLoginEligibilityCheck implements LoginEligibilityCheck
9+
{
10+
11+
@Override
12+
public void init(final ClientState clientState, final Properties properties)
13+
{
14+
15+
}
16+
17+
@Override
18+
public boolean isEligibleToLogin(final User user, final String loginName)
19+
{
20+
return true;
21+
}
22+
}

base/src/main/java/com/instaclustr/cassandra/ldap/conf/LdapAuthenticatorConfiguration.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.io.IOException;
2727
import java.util.Properties;
2828

29+
import com.instaclustr.cassandra.ldap.auth.NoOpLoginEligibilityCheck;
2930
import org.apache.cassandra.exceptions.ConfigurationException;
3031
import org.slf4j.Logger;
3132
import org.slf4j.LoggerFactory;
@@ -60,6 +61,19 @@ public final class LdapAuthenticatorConfiguration
6061

6162
public static final String CASSANDRA_LDAP_ADMIN_USER = "cassandra.ldap.admin.user";
6263

64+
public static final String ELIGIBILITY_CHECK_CLASS_NAME = "eligibility_class_name";
65+
public static final String DEFAULT_ELIGIBILITY_CHECK_CLASS_NAME = NoOpLoginEligibilityCheck.class.getCanonicalName();
66+
67+
public static final String CASSANDRA_ELIGIBILITY_CHECK_KEYSPACE = "eligibility_cassandra_keyspace";
68+
public static final String CASSANDRA_ELIGIBILITY_CHECK_TABLE = "eligibility_cassandra_table";
69+
public static final String CASSANDRA_ELIGIBILITY_CHECK_USER_COLUMN = "eligibility_cassandra_user_column";
70+
public static final String CASSANDRA_ELIGIBILITY_CHECK_ACCESS_COLUMN = "eligibility_cassandra_access_column";
71+
72+
public static final String DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_KEYSPACE = "login_eligibility";
73+
public static final String DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_TABLE = "login_eligibility";
74+
public static final String DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_USER_COLUMN = "user";
75+
public static final String DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_ACCESS_COLUMN = "has_access";
76+
6377
public static final String CONSISTENCY_FOR_ROLE = "consistency_for_role";
6478
public static final String DEFAULT_CONSISTENCY_FOR_ROLE = "LOCAL_ONE";
6579

@@ -139,10 +153,22 @@ public Properties parseProperties() throws ConfigurationException
139153

140154
properties.setProperty(FILTER_TEMPLATE, filterTemplate);
141155

156+
properties.setProperty(LdapAuthenticatorConfiguration.CONTEXT_FACTORY_PROP, properties.getProperty(CONTEXT_FACTORY_PROP, DEFAULT_CONTEXT_FACTORY));
157+
properties.setProperty(LdapAuthenticatorConfiguration.LDAP_URI_PROP, properties.getProperty(LDAP_URI_PROP));
158+
159+
properties.setProperty(LdapAuthenticatorConfiguration.ELIGIBILITY_CHECK_CLASS_NAME, properties.getProperty(ELIGIBILITY_CHECK_CLASS_NAME, DEFAULT_ELIGIBILITY_CHECK_CLASS_NAME));
160+
161+
properties.setProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_KEYSPACE,
162+
properties.getProperty(CASSANDRA_ELIGIBILITY_CHECK_KEYSPACE, DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_KEYSPACE));
163+
164+
properties.setProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_TABLE,
165+
properties.getProperty(CASSANDRA_ELIGIBILITY_CHECK_TABLE, DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_TABLE));
142166

167+
properties.setProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_USER_COLUMN,
168+
properties.getProperty(CASSANDRA_ELIGIBILITY_CHECK_USER_COLUMN, DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_USER_COLUMN));
143169

144-
properties.put(LdapAuthenticatorConfiguration.CONTEXT_FACTORY_PROP, properties.getProperty(CONTEXT_FACTORY_PROP, DEFAULT_CONTEXT_FACTORY));
145-
properties.put(LdapAuthenticatorConfiguration.LDAP_URI_PROP, properties.getProperty(LDAP_URI_PROP));
170+
properties.setProperty(LdapAuthenticatorConfiguration.CASSANDRA_ELIGIBILITY_CHECK_ACCESS_COLUMN,
171+
properties.getProperty(CASSANDRA_ELIGIBILITY_CHECK_ACCESS_COLUMN, DEFAULT_CASSANDRA_ELIGIBILITY_CHECK_ACCESS_COLUMN));
146172

147173
return properties;
148174
}

base/src/main/java/com/instaclustr/cassandra/ldap/utils/ServiceUtils.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ public class ServiceUtils
3434

3535
private static final Logger logger = LoggerFactory.getLogger(ServiceUtils.class);
3636

37+
public static <T> T getServiceFromConfig(final Class<T> clazz,
38+
final String classNameFromConfig)
39+
{
40+
Class<?> defaultImplClazz;
41+
42+
try {
43+
defaultImplClazz = Class.forName(classNameFromConfig);
44+
} catch (final ClassNotFoundException e) {
45+
throw new IllegalStateException(format("Could not find class %s", classNameFromConfig));
46+
}
47+
48+
return getService(clazz, (Class<T>) defaultImplClazz);
49+
}
50+
3751
public static <T> T getService(final Class<T> clazz, final Class<? extends T> defaultImplClazz)
3852
{
3953
final ServiceLoader<T> loader = ServiceLoader.load(clazz);

base/src/main/java/org/apache/cassandra/auth/LDAPAuthenticator.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919

2020
import static com.instaclustr.cassandra.ldap.conf.LdapAuthenticatorConfiguration.CASSANDRA_AUTH_CACHE_ENABLED_PROP;
2121
import static com.instaclustr.cassandra.ldap.conf.LdapAuthenticatorConfiguration.CASSANDRA_LDAP_ADMIN_USER;
22+
import static com.instaclustr.cassandra.ldap.conf.LdapAuthenticatorConfiguration.ELIGIBILITY_CHECK_CLASS_NAME;
2223
import static com.instaclustr.cassandra.ldap.utils.ServiceUtils.getService;
24+
import static com.instaclustr.cassandra.ldap.utils.ServiceUtils.getServiceFromConfig;
2325
import static java.lang.Boolean.parseBoolean;
2426
import static java.lang.String.format;
2527

@@ -29,6 +31,7 @@
2931
import com.google.common.util.concurrent.UncheckedExecutionException;
3032
import com.google.common.util.concurrent.Uninterruptibles;
3133
import com.instaclustr.cassandra.ldap.AbstractLDAPAuthenticator;
34+
import com.instaclustr.cassandra.ldap.auth.LoginEligibilityCheck;
3235
import com.instaclustr.cassandra.ldap.PlainTextSaslAuthenticator;
3336
import com.instaclustr.cassandra.ldap.User;
3437
import com.instaclustr.cassandra.ldap.auth.CassandraUserRetriever;
@@ -64,6 +67,7 @@ public class LDAPAuthenticator extends AbstractLDAPAuthenticator
6467
private static final Logger logger = LoggerFactory.getLogger(AbstractLDAPAuthenticator.class);
6568

6669
protected CacheDelegate cacheDelegate;
70+
protected LoginEligibilityCheck loginEligibilityCheck;
6771

6872
public void setup()
6973
{
@@ -85,6 +89,9 @@ public void setup()
8589

8690
cacheDelegate = getService(CacheDelegate.class, null);
8791

92+
loginEligibilityCheck = getServiceFromConfig(LoginEligibilityCheck.class, properties.getProperty(ELIGIBILITY_CHECK_CLASS_NAME));
93+
loginEligibilityCheck.init(clientState, properties);
94+
8895
final String adminRole = System.getProperty(CASSANDRA_LDAP_ADMIN_USER, "cassandra");
8996

9097
while (true)
@@ -166,9 +173,16 @@ public AuthenticatedUser authenticate(String username, String password) throws A
166173

167174
final String loginName = cachedUser.getLdapDN() == null ? cachedUser.getUsername() : cachedUser.getLdapDN();
168175

169-
logger.debug("Going to log in with {}", loginName);
170-
171-
return new AuthenticatedUser(loginName);
176+
if (loginEligibilityCheck.isEligibleToLogin(cachedUser, loginName))
177+
{
178+
logger.debug("Going to log in with {}", loginName);
179+
return new AuthenticatedUser(loginName);
180+
}
181+
else
182+
{
183+
throw new AuthenticationException(String.format("User %s is authenticated against LDAP fine but it is not able "
184+
+ "to log in based on an eligibility check.", loginName));
185+
}
172186
}
173187
} catch (final UncheckedExecutionException ex)
174188
{
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.instaclustr.cassandra.ldap.auth;
2+
3+
import static java.util.Collections.singletonList;
4+
5+
import org.apache.cassandra.cql3.QueryOptions;
6+
import org.apache.cassandra.cql3.QueryProcessor;
7+
import org.apache.cassandra.cql3.statements.SelectStatement;
8+
import org.apache.cassandra.service.QueryState;
9+
import org.apache.cassandra.transport.messages.ResultMessage.Rows;
10+
import org.apache.cassandra.utils.ByteBufferUtil;
11+
12+
public class Cassandra311LoginEligibilityCheck extends BaseCassandraLoginEligibilityCheck
13+
{
14+
@Override
15+
protected Rows getRows(final String loginName)
16+
{
17+
final SelectStatement selStmt = (SelectStatement) QueryProcessor.getStatement(selectStatement, clientState).statement;
18+
return selStmt.execute(new QueryState(clientState),
19+
QueryOptions.forInternalCalls(singletonList(ByteBufferUtil.bytes(loginName))),
20+
System.nanoTime());
21+
}
22+
}

0 commit comments

Comments
 (0)