From d65156c7dce869286a56cb86cd9c8be06b97f3a7 Mon Sep 17 00:00:00 2001 From: Sam Adams Date: Thu, 10 Nov 2016 16:22:31 +0000 Subject: [PATCH] initial commit (cassandra 'grant' and 'role' modules) --- .gitignore | 1 + LICENSE | 20 +++ README.md | 44 ++++++ example-tasks/main.yml | 36 +++++ library/cassandra_grant.py | 271 +++++++++++++++++++++++++++++++++++++ library/cassandra_role.py | 226 +++++++++++++++++++++++++++++++ 6 files changed, 598 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 example-tasks/main.yml create mode 100644 library/cassandra_grant.py create mode 100644 library/cassandra_role.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cffb54f --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2015, 2016 Ensighten + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2125d01 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Cassandra Ansible Module + +Supplies modules for administering Cassandra roles and granting permissions to those roles. + +## Pre-requisites + +1. Enable authentication in Cassandra +1. Have `cassandra-driver` python package installed on the target machine + +### Enable auth in Cassandra + +E.g. set the following properties in `/etc/cassandra/cassandra.yml`: + + authenticator: PasswordAuthenticator + authorizer: CassandraAuthorizer + +Once this is updated and the cluster restarted a default user of `cassandra` with password `cassandra` will be required +to login. + +This config change and restart **must be applied before you can add permissions**. + +### Install cassandra-driver + +The package is: `cassandra-driver` and it can be install via `pip`. + +Docs: https://datastax.github.io/python-driver/api/cassandra/query.html + +## Why is there no `cassandra_user` module? + +In Cassandra's permissioning system, there are just roles. +However, roles can (optionally) login and roles can inherit other roles (so roles can be used in a very user-like way). + +A suggested setup would be have 'role' roles which *can't login* (but are granted the keyspace permissions), +and 'user' roles which *can login* and inherit their permissions from roles. + +e.g. +1. Create role `role_select_all` who can not login but is granted access to select anything from any keyspace/table. +1. Create 'pseudo-user' (a role which can login) and assign them the role `role_select_all`. + +See `example-tasks` for how this can be done. + +## Docs + +Written in comments in `library/*`. diff --git a/example-tasks/main.yml b/example-tasks/main.yml new file mode 100644 index 0000000..de21eac --- /dev/null +++ b/example-tasks/main.yml @@ -0,0 +1,36 @@ +--- + +- block: + + - name: add users (as roles) + cassandra_role: + state: present + name: a_user + superuser: False + enable_login: True + password: a_user_password + + - name: add roles + cassandra_role: + state: present + name: x_role + superuser: False + enable_login: False #so a role can't login + + - name: grant perms to roles + cassandra_grant: + mode: grant # ["grant", "revoke"] + role: x_role + permission: all # ["all", "alter", "drop", "select", "modify", "authorise", "describe", "execute"] + keyspace: "mykeyspace" + all_keyspaces: "False" + + - name: grant roles to users (aka roles to roles) + cassandra_grant: + mode: grant # ["grant", "revoke"] + role: a_user + inherit_role: x_role + + login_hosts: localhost + login_user: cassandra + login_password: cassandra \ No newline at end of file diff --git a/library/cassandra_grant.py b/library/cassandra_grant.py new file mode 100644 index 0000000..2e94b8e --- /dev/null +++ b/library/cassandra_grant.py @@ -0,0 +1,271 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + + +DOCUMENTATION = ''' +--- +module: cassandra_role + +short_description: Grant Cassandra permissions +description: + - Add/remove Cassandra Users + - requires `pip install cassandra-driver` + - Related Docs: https://datastax.github.io/python-driver/api/cassandra/query.html + - Related Docs: https://docs.datastax.com/en/cql/3.3/cql/cql_reference/grant_r.html +author: "Sam Adams" +options: + permission: + description: + - what permission to grant + required: true + choices: ["all", "create", "alter", "drop", "select", "modify", "authorize"] + keyspace: + description: + - required if `all_keyspaces` == false + - ignored if `inherit_role` is set + required: false + default: false + all_keyspaces: + description: + - if true, `on` is ignored and the `what` is granted to all keyspaces + - ignored if `inherit_role` is set + required: false + default: false + role: + description: + - which role to modify + - the role must already exist in cassandra + required: true + inherit_role: + description: + - which role permission this role should inherit (no keyspace required) + - requires that the role already exists + required: true + login_hosts: + description: + - List of hosts to login to Cassandra with + required: true + login_user: + description: + - The superuser to login to Cassandra with + required: true + login_password: + description: + - The superuser password to login to Cassandra with + required: true + login_port: + description: + - Port to connect to cassandra on + default: 9042 + mode: + description: + - Whether access should be granted or revoked. + required: false + default: grant + choices: [ "grant", "revoke" ] + +notes: + - "requires cassandra-driver to be installed" + +''' + +EXAMPLES = ''' +# Grant select for all keyspaces +- cassandra_grant: permission='select' all_keyspaces=True role=read_only login_hosts=localhost login_pass=cassandra login_user=cassandra + +# Revoke modify permission to foo keyspace +- cassandra_grant: mode=revoke permission=modify keyspace=foo role=no_modify_foo login_hosts=localhost login_pass=cassandra login_user=cassandra + +# Inherit roles +- cassandra_grant: mode=grant inherit_role=read_only role=my_user_role login_hosts=localhost login_pass=cassandra login_user=cassandra +''' + +try: + from cassandra.cluster import Cluster + from cassandra.auth import PlainTextAuthProvider + from cassandra.query import dict_factory +except ImportError: + cassandra_dep_found = False +else: + cassandra_dep_found = True + +GET_ROLE = 'SELECT * FROM system_auth.roles WHERE role = %s limit 1;' +GET_ROLE_MEMBERS = 'SELECT * FROM system_auth.role_members WHERE role = %s' +GRANT_ROLE_TO_ROLE = 'GRANT %(inherit_role)s TO %(role)s' +REVOKE_ROLE_FROM_ROLE = 'REVOKE %(inherit_role)s FROM %(role)s' +GRANT_PERMISSION_TO_ROLE_FOR_KESYPACE_FORMAT = 'GRANT {permission} ON KEYSPACE {keyspace} TO %(role)s' +REVOKE_PERMISSION_FROM_ROLE_FOR_KESYPACE_FORMAT = 'REVOKE {permission} ON KEYSPACE {keyspace} FROM %(role)s' +GRANT_PERMISSION_TO_ROLE_FOR_ALL_KESYPACES_FORMAT = 'GRANT {permission} ON ALL KEYSPACES TO %(role)s' +REVOKE_PERMISSION_FROM_ROLE_FOR_ALL_KESYPACES_FORMAT = 'REVOKE {permission} ON ALL KEYSPACES FROM %(role)s' + + +def get_role(session, name): + rows = session.execute(GET_ROLE, [name]) + for row in rows: + return row + + +def would_change(existing_role, can_login, is_superuser, password): + if password or not existing_role: + # even setting existing password updates the `salted_hash` in the db, so no way to check has changed. + return True + else: + return bool(existing_role['can_login'] != can_login or existing_role['is_superuser'] != is_superuser) + + +def role_has_role(session, role, inherit_role): + rows = session.execute(GET_ROLE_MEMBERS, [inherit_role]) + for row in rows: + if row['member'] == role: + return True + return False + + +def assign_role(session, check_mode, is_revoke, inherit_role, role): + has_role = role_has_role(session, role, inherit_role) + + # check if we need to do anything + if has_role and not is_revoke: + return False + elif not has_role and is_revoke: + return False + + if not is_revoke: + query = GRANT_ROLE_TO_ROLE + else: + query = REVOKE_ROLE_FROM_ROLE + + if not check_mode: + session.execute(query, {'role': role, 'inherit_role': inherit_role}) + + return True + + +def grant_role_permission(session, in_check_mode, is_revoke, permission, all_keyspaces, keyspace, role): + permission = permission.upper() + if is_revoke and all_keyspaces: + # revoking for all keyspaces + query = REVOKE_PERMISSION_FROM_ROLE_FOR_ALL_KESYPACES_FORMAT.format(permission=permission) + elif all_keyspaces: + # granting for all keyspaces + query = GRANT_PERMISSION_TO_ROLE_FOR_ALL_KESYPACES_FORMAT.format(permission=permission) + elif is_revoke: + # revoking for a specific keyspace + query = REVOKE_PERMISSION_FROM_ROLE_FOR_KESYPACE_FORMAT.format(permission=permission, keyspace=keyspace) + else: + # granting for a specific keyspace + query = GRANT_PERMISSION_TO_ROLE_FOR_KESYPACE_FORMAT.format(permission=permission, keyspace=keyspace) + + if not in_check_mode: + session.execute(query, {'role': role}) + + # too complex to work out what will/has changed + return True + + +def grant_access(session, in_check_mode, permission, role, inherit_role, keyspace, all_keyspaces, mode): + if keyspace and all_keyspaces: + raise Exception("Specify a keyspace or all keyspaces, not both") + if keyspace and inherit_role: + raise Exception("If you are inheriting a role you can't specify a keyspace") + if all_keyspaces and inherit_role: + raise Exception("If you are inheriting a role you can't specify all keyspaces") + + mode = mode.upper() + is_revoke = mode != 'GRANT' + + if inherit_role: + return assign_role(session, in_check_mode, is_revoke, inherit_role, role) + else: + return grant_role_permission(session, in_check_mode, is_revoke, permission, all_keyspaces, keyspace, role) + + +def main(): + module = AnsibleModule( + argument_spec={ + 'login_user': { + 'required': True, + 'type': 'str' + }, + 'login_password': { + 'required': True, + 'no_log': True, + 'type': 'str' + }, + 'login_hosts': { + 'required': True, + 'type': 'list' + }, + 'login_port': { + 'default': 9042, + 'type': 'int' + }, + 'permission': { + 'required': False, + 'choices': ["all", "create", "alter", "drop", "select", "modify", "authorize"] + }, + 'role': { + 'required': True, + 'aliases': ['name'] + }, + 'inherit_role': { + 'required': False, + 'default': None + }, + 'keyspace': { + 'required': False, + 'default': None, + 'type': 'str' + }, + 'all_keyspaces': { + 'default': False, + 'type': 'bool' + }, + 'mode': { + 'default': "grant", + 'choices': ["grant", "revoke"] + } + }, + supports_check_mode=True + ) + login_user = module.params["login_user"] + login_password = module.params["login_password"] + login_hosts = module.params["login_hosts"] + login_port = module.params["login_port"] + permission = module.params["permission"] + role = module.params["role"] + inherit_role = module.params["inherit_role"] + keyspace = module.params["keyspace"] + all_keyspaces = module.params["all_keyspaces"] + mode = module.params["mode"] + + if not cassandra_dep_found: + module.fail_json(msg="the python cassandra-driver module is required") + + session = None + changed = False + try: + if not login_user: + cluster = Cluster(login_hosts, port=login_port) + else: + auth_provider = PlainTextAuthProvider(username=login_user, password=login_password) + cluster = Cluster(login_hosts, auth_provider=auth_provider, protocol_version=2, port=login_port) + session = cluster.connect() + session.row_factory = dict_factory + except Exception, e: + module.fail_json( + msg="unable to connect to cassandra, check login_user and login_password are correct. Exception message: %s" + % e) + + try: + changed = grant_access(session, module.check_mode, permission, role, inherit_role, keyspace, all_keyspaces, + mode) + except Exception, e: + module.fail_json(msg=str(e)) + module.exit_json(changed=changed, name=role) + + +from ansible.module_utils.basic import * + +if __name__ == '__main__': + main() diff --git a/library/cassandra_role.py b/library/cassandra_role.py new file mode 100644 index 0000000..202fa25 --- /dev/null +++ b/library/cassandra_role.py @@ -0,0 +1,226 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + + +DOCUMENTATION = ''' +--- +module: cassandra_role + +short_description: Manage Cassandra Roles +description: + - Add/remove Cassandra Users + - requires `pip install cassandra-driver` + - Related Docs: https://datastax.github.io/python-driver/api/cassandra/query.html + - Related Docs: https://docs.datastax.com/en/cql/3.3/cql/cql_reference/create_role.html +author: "Sam Adams" +options: + name: + description: + - name of the role to add or remove + required: true + alias: role + password: + description: + - Set the role's password. Setting password will always elicit a 'change' (even if is the same). + required: true + superuser: + description: + - Create the user as a superuser? + required: false + default: False + login_hosts: + description: + - List of hosts to login to Cassandra with + required: true + login_user: + description: + - The superuser to login to Cassandra with + required: true + login_password: + description: + - The superuser password to login to Cassandra with + required: true + login_port: + description: + - Port to connect to cassandra on + default: 9042 + state: + description: + - Whether the role should exist. When C(absent), removes + the role. + required: false + default: present + choices: [ "present", "absent" ] + +notes: + - "requires cassandra-driver to be installed" + +''' + +EXAMPLES = ''' +# Create Role +- cassandra_role: name='foo' password='12345' state=present superuser=False login_hosts=localhost login_pass=cassandra login_user=cassandra + +# Remove Role +- cassandra_role: name='foo' state=absent login_hosts=localhost login_pass=cassandra login_user=cassandra +''' + +try: + from cassandra.cluster import Cluster + from cassandra.auth import PlainTextAuthProvider + from cassandra.query import dict_factory +except ImportError: + cassandra_dep_found = False +else: + cassandra_dep_found = True + +GET_ROLE = 'SELECT * FROM system_auth.roles WHERE role = %s limit 1;' +DROP_ROLE = 'DROP ROLE %s' +ALTER_ROLE_WITH_PASS = 'ALTER ROLE %s WITH PASSWORD = %s AND LOGIN = %s AND SUPERUSER = %s' +CREATE_ROLE_WITH_PASS = 'CREATE ROLE %s WITH PASSWORD = %s AND LOGIN = %s AND SUPERUSER = %s' +CREATE_ROLE_NO_PASS = 'CREATE ROLE %s WITH LOGIN = %s AND SUPERUSER = %s' +ALTER_ROLE_NO_PASS = 'ALTER ROLE %s WITH LOGIN = %s AND SUPERUSER = %s' + + +def role_delete(session, in_check_mode, name): + existing_role = get_role(session, name) + if bool(existing_role): + if not in_check_mode: + session.execute(DROP_ROLE, [name]) + return True + else: + return False + + +def get_role(session, name): + rows = session.execute(GET_ROLE, [name]) + for row in rows: + return row + + +def role_save(session, check_mode, name, password, can_login, is_superuser): + existing_role = get_role(session, name) + if check_mode: + return would_change(existing_role, can_login, is_superuser, password) + + do_save(session, existing_role, is_superuser, name, password, can_login) + + new_user = get_role(session, name) + return bool(new_user != existing_role) + + +def do_save(session, existing_role, is_superuser, name, password, can_login): + existing_role = bool(existing_role) + + if bool(password): + params = (name, password, can_login, is_superuser) + if existing_role: + query = ALTER_ROLE_WITH_PASS + else: + query = CREATE_ROLE_WITH_PASS + else: + params = (name, can_login, is_superuser) + if existing_role: + query = ALTER_ROLE_NO_PASS + else: + query = CREATE_ROLE_NO_PASS + session.execute(query, params) + + +def would_change(existing_role, can_login, is_superuser, password): + if password or not existing_role: + # even setting existing password updates the `salted_hash` in the db, so no way to check has changed. + return True + else: + return bool(existing_role['can_login'] != can_login or existing_role['is_superuser'] != is_superuser) + + +def main(): + module = AnsibleModule( + argument_spec={ + 'login_user': { + 'required': True, + 'type': 'str' + }, + 'login_password': { + 'required': True, + 'no_log': True, + 'type': 'str' + }, + 'login_hosts': { + 'required': True, + 'type': 'list' + }, + 'login_port': { + 'default': 9042, + 'type': 'int' + }, + 'name': { + 'required': True, + 'aliases': ['role'] + }, + 'password': { + 'default': None, + 'no_log': True + }, + 'enable_login': { + 'default': False, + 'type': 'bool' + }, + 'superuser': { + 'default': False, + 'type': 'bool' + }, + 'state': { + 'default': "present", + 'choices': ["absent", "present"] + } + }, + supports_check_mode=True + ) + login_user = module.params["login_user"] + login_password = module.params["login_password"] + login_hosts = module.params["login_hosts"] + login_port = module.params["login_port"] + enable_login = module.params["enable_login"] + name = module.params["name"] + password = module.params["password"] + superuser = module.params["superuser"] + state = module.params["state"] + + if not cassandra_dep_found: + module.fail_json(msg="the python cassandra-driver module is required") + + session = None + changed = False + try: + if not login_user: + cluster = Cluster(login_hosts, port=login_port) + + else: + auth_provider = PlainTextAuthProvider(username=login_user, password=login_password) + cluster = Cluster(login_hosts, auth_provider=auth_provider, protocol_version=2, port=login_port) + session = cluster.connect() + session.row_factory = dict_factory + except Exception, e: + module.fail_json( + msg="unable to connect to cassandra, check login_user and login_password are correct. Exception message: %s" + % e) + + if state == "present": + try: + changed = role_save(session, module.check_mode, name, password, enable_login, superuser) + except Exception, e: + module.fail_json(msg=str(e)) + elif state == "absent": + try: + changed = role_delete(session, module.check_mode, name) + except Exception, e: + module.fail_json(msg=str(e)) + module.exit_json(changed=changed, name=name) + + +from ansible.module_utils.basic import * + +if __name__ == '__main__': + main()