__init__.py 36.9 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
    def _fill_endpoints(self, endpoints):
        astakos_service_catalog = parse_endpoints(
133
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
134 135 136 137 138 139 140 141 142 143 144 145 146 147
        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)

148 149 150 151 152 153
        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
154

155 156
    def _get_value(self, s):
        assert s in ['_account_url', '_account_prefix',
157
                     '_ui_url', '_ui_prefix',
158
                     '_oauth2_url', '_oauth2_prefix']
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180
        try:
            return getattr(self, s)
        except AttributeError:
            self.get_endpoints()
            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')

181
    @property
182 183
    def oauth2_url(self):
        return self._get_value('_oauth2_url')
184 185

    @property
186 187
    def oauth2_prefix(self):
        return self._get_value('_oauth2_prefix')
188

189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207
    @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")
208

209 210 211
    @property
    def api_commissions(self):
        return join_urls(self.account_prefix, "commissions")
212

213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235
    @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")
236

237
    @property
238 239
    def api_oauth2_auth(self):
        return join_urls(self.oauth2_prefix, "auth")
240 241

    @property
242 243
    def api_oauth2_token(self):
        return join_urls(self.oauth2_prefix, "token")
244

245
    # ----------------------------------
246 247
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
248
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
249
        """Make the actual call to Astakos Service"""
250 251
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
252
        self.logger.debug(
253 254 255 256
            "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
257

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
258
        # Check Input
259 260 261 262
        if headers is None:
            headers = {}
        if body is None:
            body = {}
263 264 265
        # Initialize log_request and log_response attributes
        self.log_request = None
        self.log_response = None
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
266

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
267 268
        # Build request's header and body
        kwargs = {}
269
        kwargs['headers'] = copy(headers)
270
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
271
        if body:
272
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
273 274 275 276 277 278
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
279
            # Get the connection object
280
            with self.conn_class(self.astakos_base_url) as conn:
281 282 283 284 285
                # 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)

286
                # Send request
287
                # Used * or ** magic. pylint: disable-msg=W0142
288
                (message, data, status) = \
289
                    _do_request(conn, method, request_path, **kwargs)
290 291 292 293 294

                # 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
295
        except Exception as err:
296
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
297 298 299 300
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
301
        if status == 400:
302
            raise BadRequest(message, data)
303
        elif status == 401:
304
            raise Unauthorized(message, data)
305
        elif status == 403:
306
            raise Forbidden(message, data)
307
        elif status == 404:
308
            raise NotFound(message, data)
309
        elif status < 200 or status >= 300:
310
            raise AstakosClientException(message, data, status)
311 312 313 314 315

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
316
                return None
317
        except Exception as err:
318 319
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
320
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
321 322

    # ----------------------------------
323
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
324
    #   with {'uuids': uuids}
325 326
    def _uuid_catalog(self, uuids, req_path):
        """Helper function to retrieve uuid catalog"""
327
        req_headers = {'content-type': 'application/json'}
328
        req_body = parse_request({'uuids': uuids}, self.logger)
329 330
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
331 332 333
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
334 335 336 337
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
                  % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
338

339
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
340 341 342 343 344 345 346 347 348
        """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

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

351
    def get_username(self, uuid):
352
        """Return the user name of a uuid (see get_usernames)"""
353
        check_input("get_username", self.logger, uuid=uuid)
354
        uuid_dict = self.get_usernames([uuid])
355 356 357
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
358
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
359

360
    def service_get_usernames(self, uuids):
361
        """Return a uuid_catalog dict using a service's token"""
362
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
363

364
    def service_get_username(self, uuid):
365
        """Return the displayName of a uuid using a service's token"""
366
        check_input("service_get_username", self.logger, uuid=uuid)
367
        uuid_dict = self.service_get_usernames([uuid])
368 369 370
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
371
            raise NoUserName(uuid)
372

373
    # ----------------------------------
374
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
375
    #   with {'displaynames': display_names}
376 377
    def _displayname_catalog(self, display_names, req_path):
        """Helper function to retrieve display names catalog"""
378
        req_headers = {'content-type': 'application/json'}
379
        req_body = parse_request({'displaynames': display_names}, self.logger)
380 381
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
382 383 384
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
385 386 387 388
            msg = "_displayname_catalog request returned %s. " \
                  "No displayname_catalog found" % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
389

390
    def get_uuids(self, display_names):
391 392 393 394 395 396 397 398 399
        """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

        """
400 401
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
402

403
    def get_uuid(self, display_name):
404
        """Return the uuid of a name (see getUUIDs)"""
405
        check_input("get_uuid", self.logger, display_name=display_name)
406
        name_dict = self.get_uuids([display_name])
407 408 409 410
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
411

412
    def service_get_uuids(self, display_names):
413
        """Return a display_name catalog using a service's token"""
414 415
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
416

417
    def service_get_uuid(self, display_name):
418
        """Return the uuid of a name using a service's token"""
419
        check_input("service_get_uuid", self.logger, display_name=display_name)
420
        name_dict = self.service_get_uuids([display_name])
421 422 423 424
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
425

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
426
    # ----------------------------------
427
    # do a GET to ``API_GETSERVICES``
428
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
429
        """Return a list of dicts with the registered services"""
430
        return self._call_astakos(self.api_getservices)
431 432

    # ----------------------------------
433
    # do a GET to ``API_RESOURCES``
434 435
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
436
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
437

438 439
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
440
    def send_feedback(self, message, data):
441 442 443 444 445 446 447 448 449 450 451 452 453
        """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})
454 455
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
456

457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475
    # -----------------------------------------
    # do a POST to ``API_TOKENS`` with no token
    def get_endpoints(self):
        """ Get services' endpoints

        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)
        self._fill_endpoints(r)
        return r

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

        Keyword arguments:
479
        tenant_name         -- user's uniq id (optional)
480 481

        It returns back the token as well as information about the token
482
        holder and the services he/she can access (in json format).
483 484 485 486

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

487 488 489 490
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
491 492 493 494 495 496 497 498 499
        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
500

501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523
    # --------------------------------------
    # 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)

524
    # ----------------------------------
525
    # do a GET to ``API_QUOTAS``
526
    def get_quotas(self):
527 528 529 530 531 532
        """Get user's quotas

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

        """
533
        return self._call_astakos(self.api_quotas)
534

535
    # ----------------------------------
536
    # do a GET to ``API_SERVICE_QUOTAS``
537
    def service_get_quotas(self, user=None):
538 539 540
        """Get all quotas for resources associated with the service

        Keyword arguments:
541
        user    -- optionally, the uuid of a specific user
542 543

        In case of success return a dict of dicts of dicts with current quotas
544
        for all users, or of a specified user, if user argument is set.
545 546 547
        Otherwise raise an AstakosClientException

        """
548
        query = self.api_service_quotas
549 550
        if user is not None:
            query += "?user=" + user
551
        return self._call_astakos(query)
552

553
    # ----------------------------------
554
    # do a POST to ``API_COMMISSIONS``
555
    def issue_commission(self, request):
556 557 558 559 560 561 562 563 564 565
        """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'}
566
        req_body = parse_request(request, self.logger)
567
        try:
568 569 570 571
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
572 573 574 575 576 577 578 579 580
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
581 582 583 584
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
585

586
    def issue_one_commission(self, holder, source, provisions,
587
                             name="", force=False, auto_accept=False):
588 589 590 591 592
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
593
        provisions  -- resources with their quantity (dict from string to int)
594
        name        -- description of the commission (string)
595 596 597 598 599 600 601 602
        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)

        """
603 604 605
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
606 607 608 609

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
610
        request["name"] = name
611 612
        try:
            request["provisions"] = []
613
            for resource, quantity in provisions.iteritems():
614 615 616
                prov = {"holder": holder, "source": source,
                        "resource": resource, "quantity": quantity}
                request["provisions"].append(prov)
617 618 619 620
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

621
        return self.issue_commission(request)
622

623
    # ----------------------------------
624
    # do a GET to ``API_COMMISSIONS``
625
    def get_pending_commissions(self):
626 627 628 629 630 631
        """Get Pending Commissions

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

        """
632
        return self._call_astakos(self.api_commissions)
633

634
    # ----------------------------------
635
    # do a GET to ``API_COMMISSIONS``/<serial>
636
    def get_commission_info(self, serial):
637 638 639 640 641 642
        """Get Description of a Commission

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

        In case of success return a dict of dicts containing
643
        informations (details) about the requested commission
644 645

        """
646
        check_input("get_commission_info", self.logger, serial=serial)
647

648 649
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
650

651
    # ----------------------------------
652
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
653
    def commission_action(self, serial, action):
654
        """Perform a commission action
655 656 657 658 659 660 661 662

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

        In case of success return nothing.

        """
663 664
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
665

666
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
667
        req_headers = {'content-type': 'application/json'}
668
        req_body = parse_request({str(action): ""}, self.logger)
669 670
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
671

672
    def accept_commission(self, serial):
673
        """Accept a commission (see commission_action)"""
674
        self.commission_action(serial, "accept")
675

676
    def reject_commission(self, serial):
677
        """Reject a commission (see commission_action)"""
678
        self.commission_action(serial, "reject")
679

680
    # ----------------------------------
681
    # do a POST to ``API_COMMISSIONS_ACTION``
682
    def resolve_commissions(self, accept_serials, reject_serials):
683 684 685 686 687 688 689 690 691 692 693
        """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.

        """
694 695 696
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
697 698 699 700 701

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

706 707
    # ----------------------------
    # do a GET to ``API_PROJECTS``
708
    def get_projects(self, name=None, state=None, owner=None):
709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727
        """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)
728 729
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
730 731 732

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
733
    def get_project(self, project_id):
734 735 736 737 738 739 740
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
741 742
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
743 744 745

    # -----------------------------
    # do a POST to ``API_PROJECTS``
746
    def create_project(self, specs):
747 748 749 750 751 752 753 754 755
        """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)
756 757 758
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
759 760 761

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
762
    def modify_project(self, project_id, specs):
763 764 765 766 767 768 769 770
        """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.
        """
771
        path = join_urls(self.api_projects, str(project_id))
772 773
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
774 775
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
776 777 778

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
779
    def project_action(self, project_id, action, reason=""):
780 781 782 783 784 785 786 787 788 789
        """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.
        """
790
        path = join_urls(self.api_projects, str(project_id))
791 792 793
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
794 795
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
796 797 798

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
799
    def get_applications(self, project=None):
800 801 802 803 804 805 806 807 808 809
        """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
810 811
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
812 813 814

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
815
    def get_application(self, app_id):
816 817 818 819 820 821 822
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
823 824
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
825 826 827

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
828
    def application_action(self, app_id, action, reason=""):
829 830 831 832 833 834 835 836 837 838
        """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.
        """
839
        path = join_urls(self.api_applications, str(app_id))
840 841 842
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
843 844
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
845 846 847

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
848
    def get_memberships(self, project=None):
849 850 851 852 853 854 855 856 857 858
        """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
859 860
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
861 862 863

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
864
    def get_membership(self, memb_id):
865 866 867 868 869 870 871
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
872 873
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
874 875 876

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
877
    def membership_action(self, memb_id, action, reason=""):
878 879 880 881 882 883 884 885 886 887
        """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.
        """
888
        path = join_urls(self.api_memberships, str(memb_id))
889 890 891
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
892 893
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
894 895 896

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
897
    def join_project(self, project_id):
898 899 900 901 902 903 904 905 906 907
        """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)
908 909
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
910 911 912

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
913
    def enroll_member(self, project_id, email):
914 915 916 917 918 919 920 921 922 923 924
        """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)
925 926 927
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")

928
    # --------------------------------
929
    # do a POST to ``API_OAUTH2_TOKEN``
930
    def get_token(self, grant_type, client_id, client_secret, **body_params):
931
        headers = {'content-type': 'application/x-www-form-urlencoded',
932 933 934 935 936
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
                                                           (client_id,
                                                            client_secret))}
        body_params['grant_type'] = grant_type
        body = urllib.urlencode(body_params)
937
        return self._call_astakos(self.api_oauth2_token, headers=headers,
938 939
                                  body=body, method="POST")

940 941 942 943 944 945 946 947 948 949 950 951 952 953

# --------------------------------------------------------------------
# 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)

954
    In case one of the `name', `type', `region', `version_id' parameters
955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989
    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()

990

991 992
# --------------------------------------------------------------------
# Private functions
993
# We want _do_request to be a distinct function
994
# so that we can replace it during unit tests.
995
def _do_request(conn, method, url, **kwargs):
996 997 998 999 1000 1001
    """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)
1002 1003
    message = response.reason
    return (message, data, status)