Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.
Merged
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
24 changes: 24 additions & 0 deletions docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,30 @@ following statements hold true:
connect=True,
auto_renew=True)


************************************
Identity and Access Management (IAM)
************************************

IBM Cloud Identity & Access Management enables you to securely authenticate
users and control access to all cloud resources consistently in the IBM Bluemix
Cloud Platform.

See `IBM Cloud Identity and Access Management <https://console.bluemix.net/docs/services/Cloudant/guides/iam.html#ibm-cloud-identity-and-access-management>`_
for more information.

The production IAM token service at *https://iam.bluemix.net/oidc/token* is used
by default. You can set an ``IAM_TOKEN_URL`` environment variable to override
this.

You can easily connect to your Cloudant account using an IAM API key:

.. code-block:: python

# Authenticate using an IAM API key
client = Cloudant.iam(ACCOUNT_NAME, API_KEY, connect=True)
Copy link
Member

Choose a reason for hiding this comment

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

Do you want to mention here the environment variable that allows for different IAM servers? (Note I query below whether it should be an env var rather than a kwarg).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added in 3b4edd9.

Copy link
Member

Choose a reason for hiding this comment

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

ok



****************
Resource sharing
****************
Expand Down
6 changes: 5 additions & 1 deletion src/cloudant/_2to3.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) 2016 IBM. All rights reserved.
# Copyright (c) 2016, 2017 IBM. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -39,7 +39,9 @@
# pylint: disable=wrong-import-position,no-name-in-module,import-error,unused-import
from urllib import quote as url_quote, quote_plus as url_quote_plus
from urlparse import urlparse as url_parse
from urlparse import urljoin as url_join
from ConfigParser import RawConfigParser
from cookielib import Cookie

def iteritems_(adict):
"""
Expand All @@ -60,9 +62,11 @@ def next_(itr):
return itr.next()
else:
from urllib.parse import urlparse as url_parse # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
from urllib.parse import urljoin as url_join # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
from urllib.parse import quote as url_quote # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
from urllib.parse import quote_plus as url_quote_plus # pylint: disable=wrong-import-position,no-name-in-module,import-error,ungrouped-imports
from configparser import RawConfigParser # pylint: disable=wrong-import-position,no-name-in-module,import-error
from http.cookiejar import Cookie # pylint: disable=wrong-import-position,no-name-in-module,import-error

def iteritems_(adict):
"""
Expand Down
31 changes: 30 additions & 1 deletion src/cloudant/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python
# Copyright (c) 2015 IBM. All rights reserved.
# Copyright (c) 2015, 2017 IBM. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -62,6 +62,35 @@ def cloudant(user, passwd, **kwargs):
yield cloudant_session
cloudant_session.disconnect()

@contextlib.contextmanager
def cloudant_iam(account_name, api_key, **kwargs):
"""
Provides a context manager to create a Cloudant session using IAM
authentication and provide access to databases, docs etc.

:param account_name: Cloudant account name.
:param api_key: IAM authentication API key.

For example:

.. code-block:: python

# cloudant context manager
from cloudant import cloudant_iam

with cloudant_iam(ACCOUNT_NAME, API_KEY) as client:
# Context handles connect() and disconnect() for you.
# Perform library operations within this context. Such as:
print client.all_dbs()
# ...

"""
cloudant_session = Cloudant.iam(account_name, api_key, **kwargs)

cloudant_session.connect()
yield cloudant_session
cloudant_session.disconnect()

@contextlib.contextmanager
def cloudant_bluemix(vcap_services, instance_name=None, **kwargs):
"""
Expand Down
220 changes: 184 additions & 36 deletions src/cloudant/_common_util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python
# Copyright (c) 2015, 2016, 2017 IBM Corp. All rights reserved.
# Copyright (c) 2015, 2017 IBM Corp. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -17,13 +17,14 @@
throughout the library.
"""

import os
import sys
import platform
from collections import Sequence
import json
from requests import Session
from requests import RequestException, Session

from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse
from ._2to3 import LONGTYPE, STRTYPE, NONETYPE, UNITYPE, iteritems_, url_join
from .error import CloudantArgumentError, CloudantException

# Library Constants
Expand Down Expand Up @@ -276,6 +277,7 @@ def append_response_error_content(response, **kwargs):

# Classes


class _Code(str):
"""
Wraps a ``str`` object as a _Code object providing the means to handle
Expand All @@ -287,66 +289,212 @@ def __new__(cls, code):
return str.__new__(cls, code.encode('utf8'))
return str.__new__(cls, code)

class InfiniteSession(Session):

class ClientSession(Session):
Copy link
Member

Choose a reason for hiding this comment

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

The majority of the work that CookieSession and IAMSession have to do is the same, other than the extra piece to get an IAM token (and the content-type of the POST to *_session which should be application/json for IAM).
It would be nice (but by no means essential if it is harder than it initially looks) if we could use the same login/logout/expired checks in this base class and just customize them slightly (e.g. with the url/data/response conditions).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a little tricky as ClientSession is used directly when admin party is enabled.
I still think it's worth doing. I've raise issue #310 to track the work.

"""
This class extends Session and provides a default timeout.
"""

def __init__(self, **kwargs):
super(ClientSession, self).__init__()
self._timeout = kwargs.get('timeout', None)

def request(self, method, url, **kwargs): # pylint: disable=W0221
"""
Overrides ``requests.Session.request`` to set the timeout.
"""
resp = super(ClientSession, self).request(
method, url, timeout=self._timeout, **kwargs)

return resp


class CookieSession(ClientSession):
"""
This class provides for the ability to automatically renew session login
information in the event of expired session authentication.
This class extends ClientSession and provides cookie authentication.
"""

def __init__(self, username, password, server_url, **kwargs):
super(InfiniteSession, self).__init__()
super(CookieSession, self).__init__(**kwargs)
self._username = username
self._password = password
self._server_url = server_url
self._timeout = kwargs.get('timeout', None)
self._auto_renew = kwargs.get('auto_renew', False)
self._session_url = url_join(server_url, '_session')

def info(self):
"""
Get cookie based login user information.
"""
resp = self.get(self._session_url)
resp.raise_for_status()

return resp.json()

def login(self):
"""
Perform cookie based user login.
"""
resp = super(CookieSession, self).request(
'POST',
self._session_url,
data={'name': self._username, 'password': self._password},
)
resp.raise_for_status()

def logout(self):
"""
Logout cookie based user.
"""
resp = super(CookieSession, self).request('DELETE', self._session_url)
resp.raise_for_status()

def request(self, method, url, **kwargs): # pylint: disable=W0221
"""
Overrides ``requests.Session.request`` to perform a POST to the
_session endpoint to renew Session cookie authentication settings and
then retry the original request, if necessary.
Overrides ``requests.Session.request`` to renew the cookie and then
retry the original request (if required).
"""
resp = super(InfiniteSession, self).request(
method, url, timeout=self._timeout, **kwargs)
path = url_parse(url).path.lower()
post_to_session = method.upper() == 'POST' and path == '/_session'
resp = super(CookieSession, self).request(method, url, **kwargs)

if not self._auto_renew:
return resp

is_expired = any((
resp.status_code == 403 and
resp.json().get('error') == 'credentials_expired',
resp.status_code == 401
))
if not post_to_session and is_expired:
super(InfiniteSession, self).request(
'POST',
'/'.join([self._server_url, '_session']),
data={'name': self._username, 'password': self._password},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
resp = super(InfiniteSession, self).request(
method, url, timeout=self._timeout, **kwargs)

if is_expired:
self.login()
resp = super(CookieSession, self).request(method, url, **kwargs)

return resp

class ClientSession(Session):
def set_credentials(self, username, password):
"""
Set a new username and password.

:param str username: New username.
:param str password: New password.
"""
if username is not None:
self._username = username

if password is not None:
self._password = password


class IAMSession(ClientSession):
"""
This class extends Session and provides a default timeout.
This class extends ClientSession and provides IAM authentication.
"""

def __init__(self, username, password, server_url, **kwargs):
super(ClientSession, self).__init__()
self._username = username
self._password = password
self._server_url = server_url
self._timeout = kwargs.get('timeout', None)
def __init__(self, api_key, server_url, **kwargs):
super(IAMSession, self).__init__(**kwargs)
self._api_key = api_key
self._auto_renew = kwargs.get('auto_renew', False)
self._session_url = url_join(server_url, '_iam_session')
self._token_url = os.environ.get(
'IAM_TOKEN_URL', 'https://iam.bluemix.net/oidc/token')
Copy link
Member

Choose a reason for hiding this comment

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

Can this be made a kwarg rather than an env var? Why is it an env var?

Copy link
Member

Choose a reason for hiding this comment

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

Our view was that it should basically never be necessary to change the token URL, but we thought there was value in providing an "override" primarily for the event that for some reason the expected URL was unavailable (although it also has value in testing).

I think an env var is more suitable for this purpose in that it doesn't require a code change if, for example, one needed to switch token server to a backup to temporarily workaround some kind of outage. That was the thinking anyway.

Copy link
Member

Choose a reason for hiding this comment

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

Okay


def info(self):
"""
Get IAM cookie based login user information.
"""
resp = self.get(self._session_url)
resp.raise_for_status()

return resp.json()

def login(self):
"""
Perform IAM cookie based user login.
"""
access_token = self._get_access_token()
try:
super(IAMSession, self).request(
'POST',
self._session_url,
headers={'Content-Type': 'application/json'},
data=json.dumps({'access_token': access_token})
).raise_for_status()

except RequestException:
raise CloudantException(
'Failed to exchange IAM token with Cloudant')

def logout(self):
"""
Logout IAM cookie based user.
"""
self.cookies.clear()

def request(self, method, url, **kwargs): # pylint: disable=W0221
"""
Overrides ``requests.Session.request`` to set the timeout.
Overrides ``requests.Session.request`` to renew the IAM cookie
and then retry the original request (if required).
"""
resp = super(ClientSession, self).request(
method, url, timeout=self._timeout, **kwargs)
# The CookieJar API prevents callers from getting an individual Cookie
# object by name.
# We are forced to use the only exposed method of discarding expired
# cookies from the CookieJar. Internally this involves iterating over
# the entire CookieJar and calling `.is_expired()` on each Cookie
# object.
self.cookies.clear_expired_cookies()

if self._auto_renew and 'IAMSession' not in self.cookies.keys():
self.login()

resp = super(IAMSession, self).request(method, url, **kwargs)

if not self._auto_renew:
return resp

if resp.status_code == 401:
self.login()
resp = super(IAMSession, self).request(method, url, **kwargs)

return resp

def set_credentials(self, username, api_key):
"""
Set a new IAM API key.

:param str username: Username parameter is unused.
:param str api_key: New IAM API key.
"""
if api_key is not None:
self._api_key = api_key

def _get_access_token(self):
"""
Get IAM access token using API key.
"""
err = 'Failed to contact IAM token service'
try:
resp = super(IAMSession, self).request(
'POST',
self._token_url,
auth=('bx', 'bx'), # required for user API keys
headers={'Accepts': 'application/json'},
data={
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
'response_type': 'cloud_iam',
'apikey': self._api_key
}
)
err = resp.json().get('errorMessage', err)
resp.raise_for_status()

return resp.json()['access_token']

except KeyError:
raise CloudantException('Invalid response from IAM token service')

except RequestException:
raise CloudantException(err)


class CloudFoundryService(object):
""" Manages Cloud Foundry service configuration. """

Expand Down
Loading