diff --git a/README.md b/README.md index b62de8f53..1128031c6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # VMware Carbon Black Cloud Python SDK -**Latest Version:** 1.3.0 +**Latest Version:** 1.3.1
-**Release Date:** 08 June 2021 +**Release Date:** 15 June 2021 [![Coverage Status](https://coveralls.io/repos/github/carbonblack/carbon-black-cloud-sdk-python/badge.svg?t=Id6Baf)](https://coveralls.io/github/carbonblack/carbon-black-cloud-sdk-python) [![Codeship Status for carbonblack/carbon-black-cloud-sdk-python](https://app.codeship.com/projects/9e55a370-a772-0138-aae4-129773225755/status?branch=develop)](https://app.codeship.com/projects/402767) diff --git a/VERSION b/VERSION index f0bb29e76..3a3cd8cc8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.3.1 diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fb8e7671..270ddfd2b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,5 +1,16 @@ Changelog ================================ +CBC SDK 1.3.1 - Released June 15, 2021 +-------------------------------- + +New Features: + +* Allow the SDK to accept a pre-configured ``Session`` object to be used for access, to get around unusual configuration requirements. + +Bug Fixes: + +* Fix functions in ``Grant`` object for adding a new access profile to a user access grant. + CBC SDK 1.3.0 - Released June 8, 2021 -------------------------------- diff --git a/docs/conf.py b/docs/conf.py index 84f0b1be9..6adbdcc56 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = 'Developer Relations' # The full version, including alpha/beta/rc tags -release = '1.3.0' +release = '1.3.1' # -- General configuration --------------------------------------------------- diff --git a/docs/guides-and-resources.rst b/docs/guides-and-resources.rst index 4bd8d0a7e..a45568573 100755 --- a/docs/guides-and-resources.rst +++ b/docs/guides-and-resources.rst @@ -18,7 +18,8 @@ Guides * :doc:`workload` - Advanced protection purpose-built for securing modern workloads to reduce the attack surface and strengthen security posture. * :doc:`reputation-override` - Manage reputation overrides for known applications, IT tools or certs. * :doc:`live-response` - Live Response allows security operators to collect information and take action on remote endpoints in real time. -* :doc:`unified-binary-store` - The unified binary store (UBS) is responsible for storing all binaries and corresponding metadata for those binaries. +* :doc:`unified-binary-store` - The unified binary store (UBS) is responsible for storing all binaries and corresponding metadata for those binaries. +* :doc:`users-grants` - Work with users and access grants. Examples -------- diff --git a/docs/users-grants.rst b/docs/users-grants.rst new file mode 100644 index 000000000..650b4ba94 --- /dev/null +++ b/docs/users-grants.rst @@ -0,0 +1,198 @@ +Users and Grants +================ + +Using the Carbon Black Cloud SDK, you can work with the users in your organization, as well as their access grants +and profiles. + +Uniform Resource Names (URNs) +----------------------------- + +The various API functions that work with users and grants often make use of *uniform resource names* (URNs) that +uniquely represent various pieces of the Carbon Black Cloud environment. These pieces include: + +* **Organizations,** represented as ``psc:org:ORGKEY``, where ``ORGKEY`` is the organization's alphanumeric key value. +* The special URN ``psc:org:ORKGEY:CHILDREN``, where ``ORGKEY`` is the organization's alphanumeric key value, + refers to all the *child organizations* of that organization, but *not* the organization itself. +* **Users,** represented as ``psc:user:ORGKEY:USERID``, where ``ORGKEY`` is the organization's alphanumeric key value + and ``USERID`` is the user's numeric login ID. +* **Access roles,** represented as ``psc:role:OPT-ORGKEY:NAME``, where ``OPT-ORGKEY`` is (optionally) the alphanumeric + key value of the organization containing that role, and ``NAME`` is the name of the role. A role that does not have + an OPT-ORGKEY is a default/global role created for all organizations. + +Most of these are dealt with for you by the Carbon Black Cloud SDK. + +Getting a List of Users +----------------------- + +We can do a query on the ``User`` object to get a list of users within the organization we're accessing. + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> query = api.select(User) + >>> user_list = list(query) + >>> for user in user_list: + ... print(f"{user.first_name} {user.last_name} (#{user.login_id}) <{user.email}>") + ... + Lysa Arryn (#2345670) + Olenna Redwyne (#2345671) + Arianne Martell (#2345672) + Jorah Mormont (#2345673) + +We can restrict the query by user IDs or E-mail addresses by using the ``user_ids([str])`` or ``email_addresses([str])`` +methods on the query object returned by ``select()`` before enumerating its results. + +Modifying a User +---------------- + +A ``User`` can be modified by changing one or more of its fields and then calling its ``save()`` method. + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> user = api.select(User, 2345672) + >>> print(user.phone) + 800-555-0000 + >>> user.phone = '888-555-9753' + >>> user.save() + @ https://defense.conferdeploy.net (*) + >>> print(user.phone) + 888-555-9753 + +**Note:** A user's *role* can only be modified by updating the user's *access grant,* detailed below. + +Creating a New User +------------------- + +Creating a user may be done with the help of a *builder object,* which is returned from the ``User.create()`` +function. + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> builder = User.create(api) + >>> builder.set_first_name('Samwell').set_last_name('Tarly') + + >>> builder.set_email('starly@example.com').set_phone('800-555-8008') + + >>> builder.set_role('psc:role::BETA_SYSTEM_ADMIN') + + >>> builder.build() + +Alternately, you may construct a *template object* (a Python ``dict``) that contains the user's information and +create the user directly. + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> user_template = {'first_name': 'Selyse', 'last_name': 'Florent', 'email': 'sflorent@example.com', + ... 'phone': '877-555-9099', 'role_urn': 'psc:role::BETA_SYSTEM_ADMIN'} + >>> User.create(api, user_template) + +**Note:** A user that has just been created will *not* be visible in either the UI or in a ``User`` query as detailed +above, until the user activates their account through the invitation E-mail message and sets a password. + +User Access Grants +------------------ + +Every user object has an *access grant* object associated with it, defining the access roles they are permitted to use. +You can use the ``grant()`` method on a ``User`` to get the grant and inspect or modify it. + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> user = api.select(User, 2345672) + >>> print(f"{user.first_name} {user.last_name}") + Arianne Martell + >>> grant = user.grant() + >>> print(grant.roles) + ['psc:role::BETA_SYSTEM_ADMIN'] + >>> grant.roles = ['psc:role::BETA_VIEW_ONLY'] + >>> grant.save() + @ https://defense.conferdeploy.net + >>> print(grant.roles) + ['psc:role::psc:role::BETA_VIEW_ONLY'] + +You can see what roles your API key is able to access and assign using the ``get_permitted_role_urns()`` function: + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import Grant + >>> for index, role_urn in enumerate(Grant.get_permitted_role_urns(api)): + ... print(f"{index}. {role_urn}") + ... + 0. psc:role::BETA_LEVEL_3_ANALYST + 1. psc:role::KUBERNETES_SECURITY_DATAPLANE_ONLY + 2. psc:role::ALL_AND_LR + 3. psc:role::BETA_LEVEL_1_ANALYST + 4. psc:role::BETA_SYSTEM_ADMIN + 5. psc:role::KUBERNETES_SECURITY_DATAPLANE + 6. psc:role::VIEW_ONLY + 7. psc:role::ALL + 8. psc:role::KUBERNETES_SECURITY_ADMIN_USER + 9. psc:role::BETA_SUPER_ADMIN + 10. psc:role::KUBERNETES_SECURITY_READ_ONLY_USER + 11. psc:role::CONTAINER_IMAGE_CLI_TOOL + 12. psc:role::KUBERNETES_SECURITY_DEVOPS + 13. psc:role::BETA_VIEW_ALL + 14. psc:role::KUBERNETES_SECURITY_DEVOPS_VIEW_ONLY + 15. psc:role::BETA_LEVEL_2_ANALYST + 16. psc:role::KUBERNETES_SECURITY_DEVELOPER + +Users created in the Carbon Black Cloud console employ *access profiles* on the access grants, which allow roles for +a user to be specified for the organization and/or any child organizations. Access profiles may be accessed and +manipulated through the access grant object. + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> user = api.select(User, 3456789) + >>> grant = user.grant() + >>> for profile in grant.profiles_: + ... print(f"{profile.allowed_orgs} - {profile.roles}") + ... + ['psc:org:1A2B3C4DE'] - ['psc:role::BETA_LEVEL_3_ANALYST'] + ['psc:org:2F3G4H5JK'] - ['psc:role::BETA_LEVEL_1_ANALYST'] + +Adding an access profile may be done via the ``create_profile()`` method on ``Grant``: + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> user = api.select(User, 3450987) + >>> grant = user.grant() + >>> builder = grant.create_profile() + >>> builder.add_org('psc:org:2F3G4H5JK').add_role('psc:role::BETA_VIEW_ALL') + + >>> profile = builder.build() + {'orgs': {'allow': ['psc:org:2F3G4H5JK']}, 'roles': ['psc:role::BETA_VIEW_ALL']} + +Or it may be added via a template object (as with ``User``): + +:: + + >>> from cbc_sdk import CBCloudAPI + >>> api = CBCloudAPI(profile='sample') + >>> from cbc_sdk.platform import User + >>> user = api.select(User, 3450987) + >>> grant = user.grant() + >>> profile_template = {'orgs': {'allow': ['psc:org:2F3G4H5JK']}, 'roles': ['psc:role::BETA_VIEW_ALL']} + >>> profile = grant.create_profile(profile_template) + {'orgs': {'allow': ['psc:org:2F3G4H5JK']}, 'roles': ['psc:role::BETA_VIEW_ALL']} + diff --git a/src/cbc_sdk/__init__.py b/src/cbc_sdk/__init__.py index b3fd958d9..7e0c419a6 100644 --- a/src/cbc_sdk/__init__.py +++ b/src/cbc_sdk/__init__.py @@ -4,7 +4,7 @@ __author__ = 'Carbon Black Developer Network' __license__ = 'MIT' __copyright__ = 'Copyright 2020-2021 VMware Carbon Black' -__version__ = '1.3.0' +__version__ = '1.3.1' from .rest_api import CBCloudAPI from .cache import lru diff --git a/src/cbc_sdk/connection.py b/src/cbc_sdk/connection.py index c20ba3384..2a5d0adb0 100644 --- a/src/cbc_sdk/connection.py +++ b/src/cbc_sdk/connection.py @@ -154,7 +154,13 @@ def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool class Connection(object): """Object that encapsulates the HTTP connection to the CB server.""" - def __init__(self, credentials, integration_name=None, timeout=None, max_retries=None, **pool_kwargs): + def __init__(self, + credentials, + integration_name=None, + timeout=None, + max_retries=None, + proxy_session=None, + **pool_kwargs): """ Initialize the Connection object. @@ -163,6 +169,7 @@ def __init__(self, credentials, integration_name=None, timeout=None, max_retries integration_name (str): The integration name being used. timeout (int): The timeout value to use for HTTP requests on this connection. max_retries (int): The maximum number of times to retry a request. + proxy_session (requests.Session) custom session to be used **pool_kwargs: Additional arguments to be used to initialize connection pooling. Raises: @@ -195,7 +202,12 @@ def __init__(self, credentials, integration_name=None, timeout=None, max_retries self.token = credentials.token self.token_header = {'X-Auth-Token': self.token, 'User-Agent': user_agent} - self.session = requests.Session() + if proxy_session: + self.session = proxy_session + credentials.use_custom_proxy_session = True + else: + self.session = requests.Session() + credentials.use_custom_proxy_session = False self._timeout = timeout @@ -215,7 +227,10 @@ def __init__(self, credentials, integration_name=None, timeout=None, max_retries self.session.mount(self.server, tls_adapter) self.proxies = {} - if credentials.ignore_system_proxy: # see https://github.com/kennethreitz/requests/issues/879 + if credentials.use_custom_proxy_session: + # get the custom session proxies + self.proxies = self.session.proxies + elif credentials.ignore_system_proxy: # see https://github.com/kennethreitz/requests/issues/879 # Unfortunately, requests will look for any proxy-related environment variables and use those anyway. The # only way to solve this without side effects, is passing in empty strings for 'http' and 'https': self.proxies = { @@ -380,13 +395,19 @@ def __init__(self, *args, **kwargs): # must be None to use MAX_RETRIES in Connection __init__ max_retries = kwargs.pop("max_retries", None) + proxy_session = kwargs.pop("proxy_session", None) pool_connections = kwargs.pop("pool_connections", 1) pool_maxsize = kwargs.pop("pool_maxsize", DEFAULT_POOLSIZE) pool_block = kwargs.pop("pool_block", DEFAULT_POOLBLOCK) - self.session = Connection(self.credentials, integration_name=integration_name, timeout=timeout, - max_retries=max_retries, pool_connections=pool_connections, - pool_maxsize=pool_maxsize, pool_block=pool_block) + self.session = Connection(self.credentials, + integration_name=integration_name, + timeout=timeout, + max_retries=max_retries, + proxy_session=proxy_session, + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + pool_block=pool_block) def raise_unless_json(self, ret, expected): """ diff --git a/src/cbc_sdk/platform/grants.py b/src/cbc_sdk/platform/grants.py index f6124bc59..da0156ecd 100644 --- a/src/cbc_sdk/platform/grants.py +++ b/src/cbc_sdk/platform/grants.py @@ -17,7 +17,6 @@ from cbc_sdk.errors import ApiError, NonQueryableModel import time import copy -import uuid import logging log = logging.getLogger(__name__) @@ -114,9 +113,7 @@ def _update_object(self): str: The UUID of this profile object. """ if 'profile_uuid' not in self._info: - self._info['profile_uuid'] = str(uuid.uuid4()) - log.debug("Creating a Profile object with UUID {0:s} for Grant with ID {1:s}" - .format(self._model_unique_id, self._grant._model_unique_id)) + log.debug("Creating a Profile object for Grant with ID {0:s}".format(self._grant._model_unique_id)) url = self.urlobject.format(self._cb.credentials.org_key, self._grant._model_unique_id) ret = self._cb.post_object(url, self._info) else: @@ -198,7 +195,7 @@ def __init__(self, grant): self._cb = grant._cb self._orgs = {'allow': []} self._roles = [] - self._conditions = {'expiration': 0, 'disabled': False} + self._conditions = None self._can_manage = False def set_orgs(self, orgs_list): @@ -277,7 +274,10 @@ def set_expiration(self, expiration): Returns: ProfileBuilder: This object. """ - self._conditions['expiration'] = expiration + if self._conditions: + self._conditions['expiration'] = expiration + else: + self._conditions = {'expiration': expiration} return self def set_disabled(self, flag): @@ -290,7 +290,10 @@ def set_disabled(self, flag): Returns: ProfileBuilder: This object. """ - self._conditions['disabled'] = flag + if self._conditions: + self._conditions['disabled'] = flag + else: + self._conditions = {'disabled': flag} return self def build(self): @@ -300,7 +303,9 @@ def build(self): Returns: Profile: The new Profile object. """ - data = {'orgs': self._orgs, 'roles': self._roles, 'conditions': self._conditions} + data = {'orgs': self._orgs, 'roles': self._roles} + if self._conditions: + data['conditions'] = self._conditions profile = Grant.Profile(self._cb, self._grant, None, data) if self._grant: profile._update_object() @@ -471,8 +476,6 @@ def _update_object(self): log.debug("Creating a new Grant object for principal {0:s}".format(save_info['principal'])) save_info['profiles'] = [] for profile in self._profiles: - # presumed to be all new profiles, so create UUIDs for them - profile._info['profile_uuid'] = str(uuid.uuid4()) save_info['profiles'].append(copy.deepcopy(profile._info)) url = self.urlobject.format(self._cb.credentials.org_key) ret = self._cb.post_object(url, save_info) @@ -571,13 +574,6 @@ def create_profile(self, template=None): t = copy.deepcopy(template) if 'profile_uuid' in t: del t['profile_uuid'] - if 'conditions' not in t: - t['conditions'] = {} - if 'expiration' not in t['conditions']: - t['conditions']['expiration'] = 0 - if 'disabled' not in t['conditions']: - t['conditions']['disabled'] = False - t['can_manage'] = False profile = Grant.Profile(self._cb, self, None, t) profile._update_object() self._profiles.append(profile) diff --git a/src/cbc_sdk/platform/users.py b/src/cbc_sdk/platform/users.py index caf9f3bcf..b24f2e465 100644 --- a/src/cbc_sdk/platform/users.py +++ b/src/cbc_sdk/platform/users.py @@ -445,6 +445,7 @@ def _internal_add_profiles(self, profile_templates): """ grant = self.grant() if grant: + create_templates = [] for template in profile_templates: need_create = True for profile in grant.profiles_: @@ -454,8 +455,10 @@ def _internal_add_profiles(self, profile_templates): need_create = False break if need_create: - grant.create_profile(template) + create_templates.append(template) grant.save() + for template in create_templates: + grant.create_profile(template) else: grant_template = {'principal': self.urn, 'org_ref': self.org_urn, 'roles': [], 'principal_name': f"{self.first_name} {self.last_name}", 'profiles': profile_templates} diff --git a/src/tests/unit/fixtures/platform/mock_grants.py b/src/tests/unit/fixtures/platform/mock_grants.py index 21beba321..1717b428c 100644 --- a/src/tests/unit/fixtures/platform/mock_grants.py +++ b/src/tests/unit/fixtures/platform/mock_grants.py @@ -669,22 +669,7 @@ "disabled": True }, "can_manage": False - }, - { - "orgs": { - "allow": [ - "psc:org:test_infinity" - ], - }, - "roles": [ - "psc:role::SECOPS_ROLE_MANAGER" - ], - "conditions": { - "expiration": 0, - "disabled": False - }, - "can_manage": False - }, + } ], "org_ref": "psc:org:test", "principal_name": "Daniel Jackson", diff --git a/src/tests/unit/platform/test_grants.py b/src/tests/unit/platform/test_grants.py index 3931a7118..2f362ccb2 100644 --- a/src/tests/unit/platform/test_grants.py +++ b/src/tests/unit/platform/test_grants.py @@ -260,7 +260,6 @@ def test_create_profile_on_existing_grant(cbcsdk_mock): """Test the creation of a new profile within a grant via a builder.""" def respond_to_profile_grant(url, body, **kwargs): ret = copy.deepcopy(POST_PROFILE_IN_GRANT_RESP) - ret['profile_uuid'] = body['profile_uuid'] return ret cbcsdk_mock.mock_request('GET', '/access/v2/orgs/test/grants/psc:user:12345678:ABCDEFGH', GET_GRANT_RESP) @@ -276,7 +275,6 @@ def test_create_profile_from_template(cbcsdk_mock): """Test the creation of a new profile within a grant via a template.""" def respond_to_profile_grant(url, body, **kwargs): ret = copy.deepcopy(POST_PROFILE_IN_GRANT_RESP) - ret['profile_uuid'] = body['profile_uuid'] return ret cbcsdk_mock.mock_request('GET', '/access/v2/orgs/test/grants/psc:user:12345678:ABCDEFGH', GET_GRANT_RESP) diff --git a/src/tests/unit/platform/test_users.py b/src/tests/unit/platform/test_users.py index f2a7bc78f..46fb55f76 100644 --- a/src/tests/unit/platform/test_users.py +++ b/src/tests/unit/platform/test_users.py @@ -544,18 +544,18 @@ def test_add_profiles(cbcsdk_mock, login_id, grant_get, new_profiles, expect_put def on_put(url, body, **kwargs): nonlocal expect_put, put_was_called assert expect_put is not None - fixed_expect_put = fixup_profile_uuids(expect_put, body) - assert body == fixed_expect_put + assert body == expect_put put_was_called = True - return fixed_expect_put + return expect_put def on_profile_post(url, body, **kwargs): - nonlocal new_profiles, new_profile_count + nonlocal new_profiles, expect_new_profs, new_profile_count matched = False for template in new_profiles: matched = template_matches(body, template) or matched assert matched new_profile_count = new_profile_count + 1 + assert new_profile_count <= expect_new_profs return body cbcsdk_mock.mock_request('GET', '/appservices/v6/orgs/test/users', GET_USERS_RESP) diff --git a/src/tests/unit/test_connection.py b/src/tests/unit/test_connection.py index 2c90cd277..ffabb033e 100755 --- a/src/tests/unit/test_connection.py +++ b/src/tests/unit/test_connection.py @@ -78,6 +78,22 @@ def test_session_cert_file_and_proxies(): assert conn.proxies['https'] == 'foobie.bletch.com' +def test_session_custom_session(): + """Test to make sure custom session is passed""" + import requests + session = requests.Session() + creds = Credentials({'url': 'https://example.com', 'token': 'ABCDEFGH', 'ssl_cert_file': 'blort', + 'proxy': None}) + session.proxies = { + 'http': 'foobie.bletch.com', + 'https': 'foobie.bletch.com' + } + conn = Connection(creds, proxy_session=session) + assert conn.ssl_verify == 'blort' + assert conn.proxies['http'] == 'foobie.bletch.com' + assert conn.proxies['https'] == 'foobie.bletch.com' + + def test_session_ignore_system_proxy(): """Test to make sure the ignore system proxy parameter has the right effect.""" creds = Credentials({'url': 'https://example.com', 'token': 'ABCDEFGH', 'ignore_system_proxy': True})