__init__.py 37.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
# Copyright (C) 2012, 2013 GRNET S.A. All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
#   1. Redistributions of source code must retain the above
#      copyright notice, this list of conditions and the following
#      disclaimer.
#
#   2. Redistributions in binary form must reproduce the above
#      copyright notice, this list of conditions and the following
#      disclaimer in the documentation and/or other materials
#      provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and
# documentation are those of the authors and should not be
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
33

34 35 36 37
"""
Simple and minimal client for the Astakos authentication service
"""

38 39
import logging
import urlparse
40
import urllib
41
import hashlib
42
from base64 import b64encode
43
from copy import copy
44 45

import simplejson
46
from astakosclient.utils import \
47
    retry_dec, scheme_to_class, parse_request, check_input, join_urls
48
from astakosclient.errors import \
49
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
50
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse, NoEndpoints
51 52


53
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
54
# Astakos Client Class
55

56
def get_token_from_cookie(request, cookie_name):
57 58 59 60 61 62 63 64 65 66
    """Extract token from the cookie name provided

    Cookie should be in the same form as astakos
    service sets its cookie contents:
        <user_uniq>|<user_token>

    """
    try:
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
        return cookie_content.split("|")[1]
67
    except BaseException:
68 69 70
        return None


71 72 73
# Too many instance attributes. pylint: disable-msg=R0902
# Too many public methods. pylint: disable-msg=R0904
class AstakosClient(object):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
74 75 76
    """AstakosClient Class Implementation"""

    # ----------------------------------
77 78 79 80 81 82
    # Initialize AstakosClient Class
    # Too many arguments. pylint: disable-msg=R0913
    # Too many local variables. pylint: disable-msg=R0914
    # Too many statements. pylint: disable-msg=R0915
    def __init__(self, token, auth_url,
                 retry=0, use_pool=False, pool_size=8, logger=None):
83
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
84 85

        Keyword arguments:
86 87
        token       -- user's/service's token (string)
        auth_url    -- i.e https://accounts.example.com/identity/v2.0
88
        retry       -- how many time to retry (integer)
89 90
        use_pool    -- use objpool for http requests (boolean)
        pool_size   -- if using pool, define the pool size
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
91 92 93
        logger      -- pass a different logger

        """
94 95

        # Get logger
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
96
        if logger is None:
97 98 99 100
            logging.basicConfig(
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
                datefmt='%Y-%m-%d %H:%M:%S',
                level=logging.INFO)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
101
            logger = logging.getLogger("astakosclient")
102 103 104
        logger.debug("Intialize AstakosClient: auth_url = %s, "
                     "use_pool = %s, pool_size = %s",
                     auth_url, use_pool, pool_size)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
105

106 107
        # Check that token and auth_url (mandatory options) are given
        check_input("__init__", logger, token=token, auth_url=auth_url)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
108

109 110 111 112
        # Initialize connection class
        parsed_auth_url = urlparse.urlparse(auth_url)
        conn_class = \
            scheme_to_class(parsed_auth_url.scheme, use_pool, pool_size)
113
        if conn_class is None:
114 115 116
            msg = "Unsupported scheme: %s" % parsed_auth_url.scheme
            logger.error(msg)
            raise BadValue(msg)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
117

118
        # Save astakos base url, logger, connection class etc in our class
119
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
120
        self.logger = logger
121 122 123
        self.token = token
        self.astakos_base_url = parsed_auth_url.netloc
        self.scheme = parsed_auth_url.scheme
124
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
125

126 127 128 129 130
        # Initialize astakos api prefixes
        # API urls under auth_url
        self.auth_prefix = parsed_auth_url.path
        self.api_tokens = join_urls(self.auth_prefix, "tokens")

131 132 133 134 135 136 137 138 139 140
    def _fill_endpoints(self, endpoints, extra=False):
        """Fill the endpoints for our AstakosClient

        This will be done once (lazily) and the endpoints will be there
        to be used afterwards.
        The `extra' parameter is there for compatibility reasons. We are going
        to fill the oauth2 endpoint only if we need it. This way we are keeping
        astakosclient compatible with older Astakos version.

        """
141
        astakos_service_catalog = parse_endpoints(
142
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
143 144 145 146 147 148 149 150 151 152 153 154 155 156
        self._account_url = \
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
        parsed_account_url = urlparse.urlparse(self._account_url)

        self._account_prefix = parsed_account_url.path
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)

        self._ui_url = \
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
        parsed_ui_url = urlparse.urlparse(self._ui_url)

        self._ui_prefix = parsed_ui_url.path
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)

157 158 159 160 161 162 163
        if extra:
            oauth2_service_catalog = \
                parse_endpoints(endpoints, ep_name="astakos_oauth2")
            self._oauth2_url = \
                oauth2_service_catalog[0]['endpoints'][0]['publicURL']
            parsed_oauth2_url = urlparse.urlparse(self._oauth2_url)
            self._oauth2_prefix = parsed_oauth2_url.path
164

165
    def _get_value(self, s, extra=False):
166
        assert s in ['_account_url', '_account_prefix',
167
                     '_ui_url', '_ui_prefix',
168
                     '_oauth2_url', '_oauth2_prefix']
169 170 171
        try:
            return getattr(self, s)
        except AttributeError:
172
            self.get_endpoints(extra=extra)
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
            return getattr(self, s)

    @property
    def account_url(self):
        return self._get_value('_account_url')

    @property
    def account_prefix(self):
        return self._get_value('_account_prefix')

    @property
    def ui_url(self):
        return self._get_value('_ui_url')

    @property
    def ui_prefix(self):
        return self._get_value('_ui_prefix')

191
    @property
192
    def oauth2_url(self):
193
        return self._get_value('_oauth2_url', extra=True)
194 195

    @property
196
    def oauth2_prefix(self):
197
        return self._get_value('_oauth2_prefix', extra=True)
198

199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217
    @property
    def api_usercatalogs(self):
        return join_urls(self.account_prefix, "user_catalogs")

    @property
    def api_service_usercatalogs(self):
        return join_urls(self.account_prefix, "service/user_catalogs")

    @property
    def api_resources(self):
        return join_urls(self.account_prefix, "resources")

    @property
    def api_quotas(self):
        return join_urls(self.account_prefix, "quotas")

    @property
    def api_service_quotas(self):
        return join_urls(self.account_prefix, "service_quotas")
218

219 220 221
    @property
    def api_commissions(self):
        return join_urls(self.account_prefix, "commissions")
222

223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
    @property
    def api_commissions_action(self):
        return join_urls(self.api_commissions, "action")

    @property
    def api_feedback(self):
        return join_urls(self.account_prefix, "feedback")

    @property
    def api_projects(self):
        return join_urls(self.account_prefix, "projects")

    @property
    def api_applications(self):
        return join_urls(self.api_projects, "apps")

    @property
    def api_memberships(self):
        return join_urls(self.api_projects, "memberships")

    @property
    def api_getservices(self):
        return join_urls(self.ui_prefix, "get_services")
246

247
    @property
248 249
    def api_oauth2_auth(self):
        return join_urls(self.oauth2_prefix, "auth")
250 251

    @property
252 253
    def api_oauth2_token(self):
        return join_urls(self.oauth2_prefix, "token")
254

255
    # ----------------------------------
256 257
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
258
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
259
        """Make the actual call to Astakos Service"""
260 261
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
262
        self.logger.debug(
263 264 265 266
            "Make a %s request to %s, using token with hash %s, "
            "with headers %s and body %s",
            method, request_path, hashed_token.hexdigest(), headers,
            body if log_body else "(not logged)")
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
267

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
268
        # Check Input
269 270 271 272
        if headers is None:
            headers = {}
        if body is None:
            body = {}
273 274 275
        # Initialize log_request and log_response attributes
        self.log_request = None
        self.log_response = None
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
276

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
277 278
        # Build request's header and body
        kwargs = {}
279
        kwargs['headers'] = copy(headers)
280
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
281
        if body:
282
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
283 284 285 286 287 288
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
289
            # Get the connection object
290
            with self.conn_class(self.astakos_base_url) as conn:
291 292 293 294 295
                # Log the request so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_request = dict(method=method, path=request_path)
                self.log_request.update(kwargs)

296
                # Send request
297
                # Used * or ** magic. pylint: disable-msg=W0142
298
                (message, data, status) = \
299
                    _do_request(conn, method, request_path, **kwargs)
300 301 302 303 304

                # Log the response so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_response = dict(
                    status=status, message=message, data=data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
305
        except Exception as err:
306
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
307 308 309 310
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
311
        if status == 400:
312
            raise BadRequest(message, data)
313
        elif status == 401:
314
            raise Unauthorized(message, data)
315
        elif status == 403:
316
            raise Forbidden(message, data)
317
        elif status == 404:
318
            raise NotFound(message, data)
319
        elif status < 200 or status >= 300:
320
            raise AstakosClientException(message, data, status)
321 322 323 324 325

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
326
                return None
327
        except Exception as err:
328 329
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
330
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
331 332

    # ----------------------------------
333
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
334
    #   with {'uuids': uuids}
335 336
    def _uuid_catalog(self, uuids, req_path):
        """Helper function to retrieve uuid catalog"""
337
        req_headers = {'content-type': 'application/json'}
338
        req_body = parse_request({'uuids': uuids}, self.logger)
339 340
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
341 342 343
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
344 345 346 347
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
                  % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
348

349
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
350 351 352 353 354 355 356 357 358
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
        uuids   -- list of user ids (list of strings)

        The returned uuid_catalog is a dictionary with uuids as
        keys and the corresponding user names as values

        """
359
        return self._uuid_catalog(uuids, self.api_usercatalogs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
360

361
    def get_username(self, uuid):
362
        """Return the user name of a uuid (see get_usernames)"""
363
        check_input("get_username", self.logger, uuid=uuid)
364
        uuid_dict = self.get_usernames([uuid])
365 366 367
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
368
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
369

370
    def service_get_usernames(self, uuids):
371
        """Return a uuid_catalog dict using a service's token"""
372
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
373

374
    def service_get_username(self, uuid):
375
        """Return the displayName of a uuid using a service's token"""
376
        check_input("service_get_username", self.logger, uuid=uuid)
377
        uuid_dict = self.service_get_usernames([uuid])
378 379 380
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
381
            raise NoUserName(uuid)
382

383
    # ----------------------------------
384
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
385
    #   with {'displaynames': display_names}
386 387
    def _displayname_catalog(self, display_names, req_path):
        """Helper function to retrieve display names catalog"""
388
        req_headers = {'content-type': 'application/json'}
389
        req_body = parse_request({'displaynames': display_names}, self.logger)
390 391
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
392 393 394
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
395 396 397 398
            msg = "_displayname_catalog request returned %s. " \
                  "No displayname_catalog found" % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
399

400
    def get_uuids(self, display_names):
401 402 403 404 405 406 407 408 409
        """Return a displayname_catalog for the given names

        Keyword arguments:
        display_names   -- list of user names (list of strings)

        The returned displayname_catalog is a dictionary with
        the names as keys and the corresponding uuids as values

        """
410 411
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
412

413
    def get_uuid(self, display_name):
414
        """Return the uuid of a name (see getUUIDs)"""
415
        check_input("get_uuid", self.logger, display_name=display_name)
416
        name_dict = self.get_uuids([display_name])
417 418 419 420
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
421

422
    def service_get_uuids(self, display_names):
423
        """Return a display_name catalog using a service's token"""
424 425
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
426

427
    def service_get_uuid(self, display_name):
428
        """Return the uuid of a name using a service's token"""
429
        check_input("service_get_uuid", self.logger, display_name=display_name)
430
        name_dict = self.service_get_uuids([display_name])
431 432 433 434
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
435

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
436
    # ----------------------------------
437
    # do a GET to ``API_GETSERVICES``
438
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
439
        """Return a list of dicts with the registered services"""
440
        return self._call_astakos(self.api_getservices)
441 442

    # ----------------------------------
443
    # do a GET to ``API_RESOURCES``
444 445
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
446
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
447

448 449
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
450
    def send_feedback(self, message, data):
451 452 453 454 455 456 457 458 459 460 461 462 463
        """Send feedback to astakos service

        keyword arguments:
        message     -- Feedback message
        data        -- Additional information about service client status

        In case of success return nothing.
        Otherwise raise an AstakosClientException

        """
        check_input("send_feedback", self.logger, message=message, data=data)
        req_body = urllib.urlencode(
            {'feedback_msg': message, 'feedback_data': data})
464 465
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
466

467 468
    # -----------------------------------------
    # do a POST to ``API_TOKENS`` with no token
469
    def get_endpoints(self, extra=False):
470 471
        """ Get services' endpoints

472
        The extra parameter is to be used by _fill_endpoints.
473 474 475 476 477 478 479 480
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
        req_body = None
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
481
        self._fill_endpoints(r, extra=extra)
482 483 484 485 486
        return r

    # --------------------------------------
    # do a POST to ``API_TOKENS`` with a token
    def authenticate(self, tenant_name=None):
487
        """ Authenticate and get services' endpoints
488 489

        Keyword arguments:
490
        tenant_name         -- user's uniq id (optional)
491 492

        It returns back the token as well as information about the token
493
        holder and the services he/she can access (in json format).
494 495 496 497

        The tenant_name is optional and if it is given it must match the
        user's uuid.

498 499 500 501
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
502 503 504 505 506 507 508 509 510
        body = {'auth': {'token': {'id': self.token}}}
        if tenant_name is not None:
            body['auth']['tenantName'] = tenant_name
        req_body = parse_request(body, self.logger)
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
        self._fill_endpoints(r)
        return r
511

512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534
    # --------------------------------------
    # do a GET to ``API_TOKENS`` with a token
    def validate_token(self, token_id, belongsTo=None):
        """ Validate a temporary access token (oath2)

        Keyword arguments:
        belongsTo         -- confirm that token belongs to tenant

        It returns back the token as well as information about the token
        holder.

        The belongsTo is optional and if it is given it must be inside the
        token's scope.

        In case of error raise an AstakosClientException.

        """
        path = join_urls(self.api_tokens, str(token_id))
        if belongsTo is not None:
            params = {'belongsTo': belongsTo}
            path = '%s?%s' % (path, urllib.urlencode(params))
        return self._call_astakos(path, method="GET", log_body=False)

535
    # ----------------------------------
536
    # do a GET to ``API_QUOTAS``
537
    def get_quotas(self):
538 539 540 541 542 543
        """Get user's quotas

        In case of success return a dict of dicts with user's current quotas.
        Otherwise raise an AstakosClientException

        """
544
        return self._call_astakos(self.api_quotas)
545

546
    # ----------------------------------
547
    # do a GET to ``API_SERVICE_QUOTAS``
548
    def service_get_quotas(self, user=None):
549 550 551
        """Get all quotas for resources associated with the service

        Keyword arguments:
552
        user    -- optionally, the uuid of a specific user
553 554

        In case of success return a dict of dicts of dicts with current quotas
555
        for all users, or of a specified user, if user argument is set.
556 557 558
        Otherwise raise an AstakosClientException

        """
559
        query = self.api_service_quotas
560 561
        if user is not None:
            query += "?user=" + user
562
        return self._call_astakos(query)
563

564
    # ----------------------------------
565
    # do a POST to ``API_COMMISSIONS``
566
    def issue_commission(self, request):
567 568 569 570 571 572 573 574 575 576
        """Issue a commission

        Keyword arguments:
        request -- commision request (dict)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
577
        req_body = parse_request(request, self.logger)
578
        try:
579 580 581 582
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
583 584 585 586 587 588 589 590 591
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
592 593 594 595
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
596

597
    def issue_one_commission(self, holder, source, provisions,
598
                             name="", force=False, auto_accept=False):
599 600 601 602 603
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
604
        provisions  -- resources with their quantity (dict from string to int)
605
        name        -- description of the commission (string)
606 607 608 609 610 611 612 613
        force       -- force this commission (boolean)
        auto_accept -- auto accept this commission (boolean)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.
        (See also issue_commission)

        """
614 615 616
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
617 618 619 620

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
621
        request["name"] = name
622 623
        try:
            request["provisions"] = []
624
            for resource, quantity in provisions.iteritems():
625 626 627
                prov = {"holder": holder, "source": source,
                        "resource": resource, "quantity": quantity}
                request["provisions"].append(prov)
628 629 630 631
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

632
        return self.issue_commission(request)
633

634
    # ----------------------------------
635
    # do a GET to ``API_COMMISSIONS``
636
    def get_pending_commissions(self):
637 638 639 640 641 642
        """Get Pending Commissions

        In case of success return a list of pending commissions' ids
        (list of integers)

        """
643
        return self._call_astakos(self.api_commissions)
644

645
    # ----------------------------------
646
    # do a GET to ``API_COMMISSIONS``/<serial>
647
    def get_commission_info(self, serial):
648 649 650 651 652 653
        """Get Description of a Commission

        Keyword arguments:
        serial  -- commission's id (int)

        In case of success return a dict of dicts containing
654
        informations (details) about the requested commission
655 656

        """
657
        check_input("get_commission_info", self.logger, serial=serial)
658

659 660
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
661

662
    # ----------------------------------
663
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
664
    def commission_action(self, serial, action):
665
        """Perform a commission action
666 667 668 669 670 671 672 673

        Keyword arguments:
        serial  -- commission's id (int)
        action  -- action to perform, currently accept/reject (string)

        In case of success return nothing.

        """
674 675
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
676

677
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
678
        req_headers = {'content-type': 'application/json'}
679
        req_body = parse_request({str(action): ""}, self.logger)
680 681
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
682

683
    def accept_commission(self, serial):
684
        """Accept a commission (see commission_action)"""
685
        self.commission_action(serial, "accept")
686

687
    def reject_commission(self, serial):
688
        """Reject a commission (see commission_action)"""
689
        self.commission_action(serial, "reject")
690

691
    # ----------------------------------
692
    # do a POST to ``API_COMMISSIONS_ACTION``
693
    def resolve_commissions(self, accept_serials, reject_serials):
694 695 696 697 698 699 700 701 702 703 704
        """Resolve multiple commissions at once

        Keyword arguments:
        accept_serials  -- commissions to accept (list of ints)
        reject_serials  -- commissions to reject (list of ints)

        In case of success return a dict of dicts describing which
        commissions accepted, which rejected and which failed to
        resolved.

        """
705 706 707
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
708 709 710 711 712

        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
713 714 715
        return self._call_astakos(self.api_commissions_action,
                                  headers=req_headers, body=req_body,
                                  method="POST")
716

717 718
    # ----------------------------
    # do a GET to ``API_PROJECTS``
719
    def get_projects(self, name=None, state=None, owner=None):
720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738
        """Retrieve all accessible projects

        Arguments:
        name  -- filter by name (optional)
        state -- filter by state (optional)
        owner -- filter by owner (optional)

        In case of success, return a list of project descriptions.
        """
        filters = {}
        if name is not None:
            filters["name"] = name
        if state is not None:
            filters["state"] = state
        if owner is not None:
            filters["owner"] = owner
        req_headers = {'content-type': 'application/json'}
        req_body = (parse_request({"filter": filters}, self.logger)
                    if filters else None)
739 740
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
741 742 743

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
744
    def get_project(self, project_id):
745 746 747 748 749 750 751
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
752 753
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
754 755 756

    # -----------------------------
    # do a POST to ``API_PROJECTS``
757
    def create_project(self, specs):
758 759 760 761 762 763 764 765 766
        """Submit application to create a new project

        Arguments:
        specs -- dict describing a project

        In case of success, return project and application identifiers.
        """
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
767 768 769
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
770 771 772

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
773
    def modify_project(self, project_id, specs):
774 775 776 777 778 779 780 781
        """Submit application to modify an existing project

        Arguments:
        project_id -- project identifier
        specs      -- dict describing a project

        In case of success, return project and application identifiers.
        """
782
        path = join_urls(self.api_projects, str(project_id))
783 784
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
785 786
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
787 788 789

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
790
    def project_action(self, project_id, action, reason=""):
791 792 793 794 795 796 797 798 799 800
        """Perform action on a project

        Arguments:
        project_id -- project identifier
        action     -- action to perform, one of "suspend", "unsuspend",
                      "terminate", "reinstate"
        reason     -- reason of performing the action

        In case of success, return nothing.
        """
801
        path = join_urls(self.api_projects, str(project_id))
802 803 804
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
805 806
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
807 808 809

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
810
    def get_applications(self, project=None):
811 812 813 814 815 816 817 818 819 820
        """Retrieve all accessible applications

        Arguments:
        project -- filter by project (optional)

        In case of success, return a list of application descriptions.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"project": project} if project is not None else None
        req_body = parse_request(body, self.logger) if body else None
821 822
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
823 824 825

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
826
    def get_application(self, app_id):
827 828 829 830 831 832 833
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
834 835
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
836 837 838

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
839
    def application_action(self, app_id, action, reason=""):
840 841 842 843 844 845 846 847 848 849
        """Perform action on an application

        Arguments:
        app_id -- application identifier
        action -- action to perform, one of "approve", "deny",
                  "dismiss", "cancel"
        reason -- reason of performing the action

        In case of success, return nothing.
        """
850
        path = join_urls(self.api_applications, str(app_id))
851 852 853
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
854 855
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
856 857 858

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
859
    def get_memberships(self, project=None):
860 861 862 863 864 865 866 867 868 869
        """Retrieve all accessible memberships

        Arguments:
        project -- filter by project (optional)

        In case of success, return a list of membership descriptions.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"project": project} if project is not None else None
        req_body = parse_request(body, self.logger) if body else None
870 871
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
872 873 874

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
875
    def get_membership(self, memb_id):
876 877 878 879 880 881 882
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
883 884
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
885 886 887

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
888
    def membership_action(self, memb_id, action, reason=""):
889 890 891 892 893 894 895 896 897 898
        """Perform action on a membership

        Arguments:
        memb_id -- membership identifier
        action  -- action to perform, one of "leave", "cancel", "accept",
                   "reject", "remove"
        reason  -- reason of performing the action

        In case of success, return nothing.
        """
899
        path = join_urls(self.api_memberships, str(memb_id))
900 901 902
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
903 904
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
905 906 907

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
908
    def join_project(self, project_id):
909 910 911 912 913 914 915 916 917 918
        """Join a project

        Arguments:
        project_id -- project identifier

        In case of success, return membership identifier.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"join": {"project": project_id}}
        req_body = parse_request(body, self.logger)
919 920
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
921 922 923

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
924
    def enroll_member(self, project_id, email):
925 926 927 928 929 930 931 932 933 934 935
        """Enroll a user in a project

        Arguments:
        project_id -- project identifier
        email      -- user identified by email

        In case of success, return membership identifier.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"enroll": {"project": project_id, "user": email}}
        req_body = parse_request(body, self.logger)
936 937 938
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")

939
    # --------------------------------
940
    # do a POST to ``API_OAUTH2_TOKEN``
941
    def get_token(self, grant_type, client_id, client_secret, **body_params):
942
        headers = {'content-type': 'application/x-www-form-urlencoded',
943 944 945 946 947
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
                                                           (client_id,
                                                            client_secret))}
        body_params['grant_type'] = grant_type
        body = urllib.urlencode(body_params)
948
        return self._call_astakos(self.api_oauth2_token, headers=headers,
949 950
                                  body=body, method="POST")

951 952 953 954 955 956 957 958 959 960 961 962 963 964

# --------------------------------------------------------------------
# parse endpoints
def parse_endpoints(endpoints, ep_name=None, ep_type=None,
                    ep_region=None, ep_version_id=None):
    """Parse endpoints server response and extract the ones needed

    Keyword arguments:
    endpoints     -- the endpoints (json response from get_endpoints)
    ep_name       -- return only endpoints with this name (optional)
    ep_type       -- return only endpoints with this type (optional)
    ep_region     -- return only endpoints with this region (optional)
    ep_version_id -- return only endpoints with this versionId (optional)

965
    In case one of the `name', `type', `region', `version_id' parameters
966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000
    is given, return only the endpoints that match all of these criteria.
    If no match is found then raise NoEndpoints exception.

    """
    try:
        catalog = endpoints['access']['serviceCatalog']
        if ep_name is not None:
            catalog = \
                [c for c in catalog if c['name'] == ep_name]
        if ep_type is not None:
            catalog = \
                [c for c in catalog if c['type'] == ep_type]
        if ep_region is not None:
            for c in catalog:
                c['endpoints'] = [e for e in c['endpoints']
                                  if e['region'] == ep_region]
            # Remove catalog entries with no endpoints
            catalog = \
                [c for c in catalog if c['endpoints']]
        if ep_version_id is not None:
            for c in catalog:
                c['endpoints'] = [e for e in c['endpoints']
                                  if e['versionId'] == ep_version_id]
            # Remove catalog entries with no endpoints
            catalog = \
                [c for c in catalog if c['endpoints']]

        if not catalog:
            raise NoEndpoints(ep_name, ep_type,
                              ep_region, ep_version_id)
        else:
            return catalog
    except KeyError:
        raise NoEndpoints()

1001

1002 1003
# --------------------------------------------------------------------
# Private functions
1004
# We want _do_request to be a distinct function
1005
# so that we can replace it during unit tests.
1006
def _do_request(conn, method, url, **kwargs):
1007 1008 1009 1010 1011 1012
    """The actual request. This function can easily be mocked"""
    conn.request(method, url, **kwargs)
    response = conn.getresponse()
    length = response.getheader('content-length', None)
    data = response.read(length)
    status = int(response.status)
1013 1014
    message = response.reason
    return (message, data, status)