/* * 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.cassandra.auth; import java.util.*; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; import com.google.common.base.*; import com.google.common.base.Objects; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.apache.cassandra.concurrent.ScheduledExecutors; import org.apache.cassandra.config.Config; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.config.Schema; import org.apache.cassandra.cql3.*; import org.apache.cassandra.cql3.statements.SelectStatement; import org.apache.cassandra.db.ConsistencyLevel; import org.apache.cassandra.db.marshal.UTF8Type; import org.apache.cassandra.exceptions.*; import org.apache.cassandra.net.MessagingService; import org.apache.cassandra.service.QueryState; import org.apache.cassandra.transport.messages.ResultMessage; import org.apache.cassandra.utils.ByteBufferUtil; import org.mindrot.jbcrypt.BCrypt; /** * Responsible for the creation, maintenance and deletion of roles * for the purposes of authentication and authorization. * Role data is stored internally, using the roles and role_members tables * in the system_auth keyspace. * * Additionally, if org.apache.cassandra.auth.PasswordAuthenticator is used, * encrypted passwords are also stored in the system_auth.roles table. This * coupling between the IAuthenticator and IRoleManager implementations exists * because setting a role's password via CQL is done with a CREATE ROLE or * ALTER ROLE statement, the processing of which is handled by IRoleManager. * As IAuthenticator is concerned only with credentials checking and has no * means to modify passwords, PasswordAuthenticator depends on * CassandraRoleManager for those functions. * * Alternative IAuthenticator implementations may be used in conjunction with * CassandraRoleManager, but WITH PASSWORD = 'password' will not be supported * in CREATE/ALTER ROLE statements. * * Such a configuration could be implemented using a custom IRoleManager that * extends CassandraRoleManager and which includes Option.PASSWORD in the Set<Option> * returned from supportedOptions/alterableOptions. Any additional processing * of the password itself (such as storing it in an alternative location) would * be added in overridden createRole and alterRole implementations. */ public class CassandraRoleManager implements IRoleManager { private static final Logger logger = LoggerFactory.getLogger(CassandraRoleManager.class); static final String DEFAULT_SUPERUSER_NAME = "cassandra"; static final String DEFAULT_SUPERUSER_PASSWORD = "cassandra"; // Transform a row in the AuthKeyspace.ROLES to a Role instance private static final Function<UntypedResultSet.Row, Role> ROW_TO_ROLE = new Function<UntypedResultSet.Row, Role>() { public Role apply(UntypedResultSet.Row row) { return new Role(row.getString("role"), row.getBoolean("is_superuser"), row.getBoolean("can_login"), row.has("member_of") ? row.getSet("member_of", UTF8Type.instance) : Collections.<String>emptySet()); } }; public static final String LEGACY_USERS_TABLE = "users"; // Transform a row in the legacy system_auth.users table to a Role instance, // used to fallback to previous schema on a mixed cluster during an upgrade private static final Function<UntypedResultSet.Row, Role> LEGACY_ROW_TO_ROLE = new Function<UntypedResultSet.Row, Role>() { public Role apply(UntypedResultSet.Row row) { return new Role(row.getString("name"), row.getBoolean("super"), true, Collections.<String>emptySet()); } }; // 2 ** GENSALT_LOG2_ROUNDS rounds of hashing will be performed. private static final String GENSALT_LOG2_ROUNDS_PROPERTY = Config.PROPERTY_PREFIX + "auth_bcrypt_gensalt_log2_rounds"; private static final int GENSALT_LOG2_ROUNDS = getGensaltLogRounds(); static int getGensaltLogRounds() { int rounds = Integer.getInteger(GENSALT_LOG2_ROUNDS_PROPERTY, 10); if (rounds < 4 || rounds > 31) throw new ConfigurationException(String.format("Bad value for system property -D%s." + "Please use a value between 4 and 31 inclusively", GENSALT_LOG2_ROUNDS_PROPERTY)); return rounds; } // NullObject returned when a supplied role name not found in AuthKeyspace.ROLES private static final Role NULL_ROLE = new Role(null, false, false, Collections.<String>emptySet()); private SelectStatement loadRoleStatement; private SelectStatement legacySelectUserStatement; private final Set<Option> supportedOptions; private final Set<Option> alterableOptions; // Will be set to true when all nodes in the cluster are on a version which supports roles (i.e. 2.2+) private volatile boolean isClusterReady = false; public CassandraRoleManager() { supportedOptions = DatabaseDescriptor.getAuthenticator().getClass() == PasswordAuthenticator.class ? ImmutableSet.of(Option.LOGIN, Option.SUPERUSER, Option.PASSWORD) : ImmutableSet.of(Option.LOGIN, Option.SUPERUSER); alterableOptions = DatabaseDescriptor.getAuthenticator().getClass().equals(PasswordAuthenticator.class) ? ImmutableSet.of(Option.PASSWORD) : ImmutableSet.<Option>of(); } public void setup() { loadRoleStatement = (SelectStatement) prepare("SELECT * from %s.%s WHERE role = ?", AuthKeyspace.NAME, AuthKeyspace.ROLES); // If the old users table exists, we may need to migrate the legacy authn // data to the new table. We also need to prepare a statement to read from // it, so we can continue to use the old tables while the cluster is upgraded. // Otherwise, we may need to create a default superuser role to enable others // to be added. if (Schema.instance.getCFMetaData(AuthKeyspace.NAME, "users") != null) { legacySelectUserStatement = (SelectStatement) prepare("SELECT * FROM %s.%s WHERE name = ?", AuthKeyspace.NAME, LEGACY_USERS_TABLE); scheduleSetupTask(() -> { convertLegacyData(); return null; }); } else { scheduleSetupTask(() -> { setupDefaultRole(); return null; }); } } public Set<Option> supportedOptions() { return supportedOptions; } public Set<Option> alterableOptions() { return alterableOptions; } public void createRole(AuthenticatedUser performer, RoleResource role, RoleOptions options) throws RequestValidationException, RequestExecutionException { String insertCql = options.getPassword().isPresent() ? String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) VALUES ('%s', %s, %s, '%s')", AuthKeyspace.NAME, AuthKeyspace.ROLES, escape(role.getRoleName()), options.getSuperuser().or(false), options.getLogin().or(false), escape(hashpw(options.getPassword().get()))) : String.format("INSERT INTO %s.%s (role, is_superuser, can_login) VALUES ('%s', %s, %s)", AuthKeyspace.NAME, AuthKeyspace.ROLES, escape(role.getRoleName()), options.getSuperuser().or(false), options.getLogin().or(false)); process(insertCql, consistencyForRole(role.getRoleName())); } public void dropRole(AuthenticatedUser performer, RoleResource role) throws RequestValidationException, RequestExecutionException { process(String.format("DELETE FROM %s.%s WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLES, escape(role.getRoleName())), consistencyForRole(role.getRoleName())); removeAllMembers(role.getRoleName()); } public void alterRole(AuthenticatedUser performer, RoleResource role, RoleOptions options) { // Unlike most of the other data access methods here, this does not use a // prepared statement in order to allow the set of assignments to be variable. String assignments = Joiner.on(',').join(Iterables.filter(optionsToAssignments(options.getOptions()), Predicates.notNull())); if (!Strings.isNullOrEmpty(assignments)) { process(String.format("UPDATE %s.%s SET %s WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLES, assignments, escape(role.getRoleName())), consistencyForRole(role.getRoleName())); } } public void grantRole(AuthenticatedUser performer, RoleResource role, RoleResource grantee) throws RequestValidationException, RequestExecutionException { if (getRoles(grantee, true).contains(role)) throw new InvalidRequestException(String.format("%s is a member of %s", grantee.getRoleName(), role.getRoleName())); if (getRoles(role, true).contains(grantee)) throw new InvalidRequestException(String.format("%s is a member of %s", role.getRoleName(), grantee.getRoleName())); modifyRoleMembership(grantee.getRoleName(), role.getRoleName(), "+"); process(String.format("INSERT INTO %s.%s (role, member) values ('%s', '%s')", AuthKeyspace.NAME, AuthKeyspace.ROLE_MEMBERS, escape(role.getRoleName()), escape(grantee.getRoleName())), consistencyForRole(role.getRoleName())); } public void revokeRole(AuthenticatedUser performer, RoleResource role, RoleResource revokee) throws RequestValidationException, RequestExecutionException { if (!getRoles(revokee, false).contains(role)) throw new InvalidRequestException(String.format("%s is not a member of %s", revokee.getRoleName(), role.getRoleName())); modifyRoleMembership(revokee.getRoleName(), role.getRoleName(), "-"); process(String.format("DELETE FROM %s.%s WHERE role = '%s' and member = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLE_MEMBERS, escape(role.getRoleName()), escape(revokee.getRoleName())), consistencyForRole(role.getRoleName())); } public Set<RoleResource> getRoles(RoleResource grantee, boolean includeInherited) throws RequestValidationException, RequestExecutionException { Set<RoleResource> roles = new HashSet<>(); Role role = getRole(grantee.getRoleName()); if (!role.equals(NULL_ROLE)) { roles.add(RoleResource.role(role.name)); collectRoles(role, roles, includeInherited); } return roles; } public Set<RoleResource> getAllRoles() throws RequestValidationException, RequestExecutionException { UntypedResultSet rows = process(String.format("SELECT role from %s.%s", AuthKeyspace.NAME, AuthKeyspace.ROLES), ConsistencyLevel.QUORUM); Iterable<RoleResource> roles = Iterables.transform(rows, new Function<UntypedResultSet.Row, RoleResource>() { public RoleResource apply(UntypedResultSet.Row row) { return RoleResource.role(row.getString("role")); } }); return ImmutableSet.<RoleResource>builder().addAll(roles).build(); } public boolean isSuper(RoleResource role) { return getRole(role.getRoleName()).isSuper; } public boolean canLogin(RoleResource role) { return getRole(role.getRoleName()).canLogin; } public Map<String, String> getCustomOptions(RoleResource role) { return Collections.emptyMap(); } public boolean isExistingRole(RoleResource role) { return getRole(role.getRoleName()) != NULL_ROLE; } public Set<? extends IResource> protectedResources() { return ImmutableSet.of(DataResource.table(AuthKeyspace.NAME, AuthKeyspace.ROLES), DataResource.table(AuthKeyspace.NAME, AuthKeyspace.ROLE_MEMBERS)); } public void validateConfiguration() throws ConfigurationException { } /* * Create the default superuser role to bootstrap role creation on a clean system. Preemptively * gives the role the default password so PasswordAuthenticator can be used to log in (if * configured) */ private static void setupDefaultRole() { try { if (!hasExistingRoles()) { QueryProcessor.process(String.format("INSERT INTO %s.%s (role, is_superuser, can_login, salted_hash) " + "VALUES ('%s', true, true, '%s')", AuthKeyspace.NAME, AuthKeyspace.ROLES, DEFAULT_SUPERUSER_NAME, escape(hashpw(DEFAULT_SUPERUSER_PASSWORD))), consistencyForRole(DEFAULT_SUPERUSER_NAME)); logger.info("Created default superuser role '{}'", DEFAULT_SUPERUSER_NAME); } } catch (RequestExecutionException e) { logger.warn("CassandraRoleManager skipped default role setup: some nodes were not ready"); throw e; } } private static boolean hasExistingRoles() throws RequestExecutionException { // Try looking up the 'cassandra' default role first, to avoid the range query if possible. String defaultSUQuery = String.format("SELECT * FROM %s.%s WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLES, DEFAULT_SUPERUSER_NAME); String allUsersQuery = String.format("SELECT * FROM %s.%s LIMIT 1", AuthKeyspace.NAME, AuthKeyspace.ROLES); return !QueryProcessor.process(defaultSUQuery, ConsistencyLevel.ONE).isEmpty() || !QueryProcessor.process(defaultSUQuery, ConsistencyLevel.QUORUM).isEmpty() || !QueryProcessor.process(allUsersQuery, ConsistencyLevel.QUORUM).isEmpty(); } private void scheduleSetupTask(final Callable<Void> setupTask) { // The delay is to give the node a chance to see its peers before attempting the operation ScheduledExecutors.optionalTasks.schedule(new Runnable() { public void run() { // If not all nodes are on 2.2, we don't want to initialize the role manager as this will confuse 2.1 // nodes (see CASSANDRA-9761 for details). So we re-schedule the setup for later, hoping that the upgrade // will be finished by then. if (!MessagingService.instance().areAllNodesAtLeast22()) { logger.trace("Not all nodes are upgraded to a version that supports Roles yet, rescheduling setup task"); scheduleSetupTask(setupTask); return; } isClusterReady = true; try { setupTask.call(); } catch (Exception e) { logger.info("Setup task failed with error, rescheduling"); scheduleSetupTask(setupTask); } } }, AuthKeyspace.SUPERUSER_SETUP_DELAY, TimeUnit.MILLISECONDS); } /* * Copy legacy auth data from the system_auth.users & system_auth.credentials tables to * the new system_auth.roles table. This setup is not performed if AllowAllAuthenticator * is configured (see Auth#setup). */ private void convertLegacyData() throws Exception { try { // read old data at QUORUM as it may contain the data for the default superuser if (Schema.instance.getCFMetaData("system_auth", "users") != null) { logger.info("Converting legacy users"); UntypedResultSet users = QueryProcessor.process("SELECT * FROM system_auth.users", ConsistencyLevel.QUORUM); for (UntypedResultSet.Row row : users) { RoleOptions options = new RoleOptions(); options.setOption(Option.SUPERUSER, row.getBoolean("super")); options.setOption(Option.LOGIN, true); createRole(null, RoleResource.role(row.getString("name")), options); } logger.info("Completed conversion of legacy users"); } if (Schema.instance.getCFMetaData("system_auth", "credentials") != null) { logger.info("Migrating legacy credentials data to new system table"); UntypedResultSet credentials = QueryProcessor.process("SELECT * FROM system_auth.credentials", ConsistencyLevel.QUORUM); for (UntypedResultSet.Row row : credentials) { // Write the password directly into the table to avoid doubly encrypting it QueryProcessor.process(String.format("UPDATE %s.%s SET salted_hash = '%s' WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLES, row.getString("salted_hash"), row.getString("username")), consistencyForRole(row.getString("username"))); } logger.info("Completed conversion of legacy credentials"); } } catch (Exception e) { logger.info("Unable to complete conversion of legacy auth data (perhaps not enough nodes are upgraded yet). " + "Conversion should not be considered complete"); logger.trace("Conversion error", e); throw e; } } private CQLStatement prepare(String template, String keyspace, String table) { try { return QueryProcessor.parseStatement(String.format(template, keyspace, table)).prepare().statement; } catch (RequestValidationException e) { throw new AssertionError(e); // not supposed to happen } } /* * Retrieve all roles granted to the given role. includeInherited specifies * whether to include only those roles granted directly or all inherited roles. */ private void collectRoles(Role role, Set<RoleResource> collected, boolean includeInherited) throws RequestValidationException, RequestExecutionException { for (String memberOf : role.memberOf) { Role granted = getRole(memberOf); if (granted.equals(NULL_ROLE)) continue; collected.add(RoleResource.role(granted.name)); if (includeInherited) collectRoles(granted, collected, true); } } /* * Get a single Role instance given the role name. This never returns null, instead it * uses the null object NULL_ROLE when a role with the given name cannot be found. So * it's always safe to call methods on the returned object without risk of NPE. */ private Role getRole(String name) { try { // If it exists, try the legacy users table in case the cluster // is in the process of being upgraded and so is running with mixed // versions of the authn schema. return (Schema.instance.getCFMetaData(AuthKeyspace.NAME, "users") != null) ? getRoleFromTable(name, legacySelectUserStatement, LEGACY_ROW_TO_ROLE) : getRoleFromTable(name, loadRoleStatement, ROW_TO_ROLE); } catch (RequestExecutionException | RequestValidationException e) { throw new RuntimeException(e); } } private Role getRoleFromTable(String name, SelectStatement statement, Function<UntypedResultSet.Row, Role> function) throws RequestExecutionException, RequestValidationException { ResultMessage.Rows rows = statement.execute(QueryState.forInternalCalls(), QueryOptions.forInternalCalls(consistencyForRole(name), Collections.singletonList(ByteBufferUtil.bytes(name)))); if (rows.result.isEmpty()) return NULL_ROLE; return function.apply(UntypedResultSet.create(rows.result).one()); } /* * Adds or removes a role name from the membership list of an entry in the roles table table * (adds if op is "+", removes if op is "-") */ private void modifyRoleMembership(String grantee, String role, String op) throws RequestExecutionException { process(String.format("UPDATE %s.%s SET member_of = member_of %s {'%s'} WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLES, op, escape(role), escape(grantee)), consistencyForRole(grantee)); } /* * Clear the membership list of the given role */ private void removeAllMembers(String role) throws RequestValidationException, RequestExecutionException { // Get the membership list of the the given role UntypedResultSet rows = process(String.format("SELECT member FROM %s.%s WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLE_MEMBERS, escape(role)), consistencyForRole(role)); if (rows.isEmpty()) return; // Update each member in the list, removing this role from its own list of granted roles for (UntypedResultSet.Row row : rows) modifyRoleMembership(row.getString("member"), role, "-"); // Finally, remove the membership list for the dropped role process(String.format("DELETE FROM %s.%s WHERE role = '%s'", AuthKeyspace.NAME, AuthKeyspace.ROLE_MEMBERS, escape(role)), consistencyForRole(role)); } /* * Convert a map of Options from a CREATE/ALTER statement into * assignment clauses used to construct a CQL UPDATE statement */ private Iterable<String> optionsToAssignments(Map<Option, Object> options) { return Iterables.transform( options.entrySet(), new Function<Map.Entry<Option, Object>, String>() { public String apply(Map.Entry<Option, Object> entry) { switch (entry.getKey()) { case LOGIN: return String.format("can_login = %s", entry.getValue()); case SUPERUSER: return String.format("is_superuser = %s", entry.getValue()); case PASSWORD: return String.format("salted_hash = '%s'", escape(hashpw((String) entry.getValue()))); default: return null; } } }); } protected static ConsistencyLevel consistencyForRole(String role) { if (role.equals(DEFAULT_SUPERUSER_NAME)) return ConsistencyLevel.QUORUM; else return ConsistencyLevel.LOCAL_ONE; } private static String hashpw(String password) { return BCrypt.hashpw(password, BCrypt.gensalt(GENSALT_LOG2_ROUNDS)); } private static String escape(String name) { return StringUtils.replace(name, "'", "''"); } /** * Executes the provided query. * This shouldn't be used during setup as this will directly return an error if the manager is not setup yet. Setup tasks * should use QueryProcessor.process directly. */ private UntypedResultSet process(String query, ConsistencyLevel consistencyLevel) throws RequestValidationException, RequestExecutionException { if (!isClusterReady) throw new InvalidRequestException("Cannot process role related query as the role manager isn't yet setup. " + "This is likely because some of nodes in the cluster are on version 2.1 or earlier. " + "You need to upgrade all nodes to Cassandra 2.2 or more to use roles."); return QueryProcessor.process(query, consistencyLevel); } private static final class Role { private String name; private final boolean isSuper; private final boolean canLogin; private Set<String> memberOf; private Role(String name, boolean isSuper, boolean canLogin, Set<String> memberOf) { this.name = name; this.isSuper = isSuper; this.canLogin = canLogin; this.memberOf = memberOf; } public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Role)) return false; Role r = (Role) o; return Objects.equal(name, r.name); } public int hashCode() { return Objects.hashCode(name); } } }