__init__.py 41.2 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 97
        if logger is None:
            logger = logging.getLogger("astakosclient")
98
            logger.setLevel(logging.INFO)
99 100 101
        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
102

103 104
        # 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
105

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

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

123 124 125 126 127
        # 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")

128 129 130 131 132 133 134 135 136 137
    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.

        """
138
        astakos_service_catalog = parse_endpoints(
139
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
140 141 142 143 144 145 146 147 148 149 150 151 152 153
        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)

154 155 156 157 158 159 160
        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
161

162
    def _get_value(self, s, extra=False):
163
        assert s in ['_account_url', '_account_prefix',
164
                     '_ui_url', '_ui_prefix',
165
                     '_oauth2_url', '_oauth2_prefix']
166 167 168
        try:
            return getattr(self, s)
        except AttributeError:
169
            self.get_endpoints(extra=extra)
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
            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')

188
    @property
189
    def oauth2_url(self):
190
        return self._get_value('_oauth2_url', extra=True)
191 192

    @property
193
    def oauth2_prefix(self):
194
        return self._get_value('_oauth2_prefix', extra=True)
195

196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214
    @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")
215

216 217 218 219
    @property
    def api_service_project_quotas(self):
        return join_urls(self.account_prefix, "service_project_quotas")

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

224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
    @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")
247

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

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

256
    # ----------------------------------
257 258
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
259
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
260
        """Make the actual call to Astakos Service"""
261 262
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
263
        self.logger.debug(
264 265 266 267
            "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
268

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

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

        try:
290
            # Get the connection object
291
            with self.conn_class(self.astakos_base_url) as conn:
292 293 294 295 296
                # 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)

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

                # 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
306
        except Exception as err:
307
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
308 309 310 311
            raise AstakosClientException(str(err))

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

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

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

350
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
351 352 353 354 355 356 357 358 359
        """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

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

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

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

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

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

401
    def get_uuids(self, display_names):
402 403 404 405 406 407 408 409 410
        """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

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

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

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

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

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

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

449 450
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
451
    def send_feedback(self, message, data):
452 453 454 455 456 457 458 459 460 461 462 463 464
        """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})
465 466
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
467

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

473
        The extra parameter is to be used by _fill_endpoints.
474 475 476 477 478 479 480 481
        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)
482
        self._fill_endpoints(r, extra=extra)
483 484 485 486 487
        return r

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

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

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

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

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

        """
        req_headers = {'content-type': 'application/json'}
503 504 505 506 507 508 509 510 511
        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
512

513 514
    # --------------------------------------
    # do a GET to ``API_TOKENS`` with a token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
515
    def validate_token(self, token_id, belongs_to=None):
516 517 518 519 520 521 522 523
        """ 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.

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
524
        The belongs_to is optional and if it is given it must be inside the
525 526 527 528 529 530
        token's scope.

        In case of error raise an AstakosClientException.

        """
        path = join_urls(self.api_tokens, str(token_id))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
531 532
        if belongs_to is not None:
            params = {'belongsTo': belongs_to}
533 534 535
            path = '%s?%s' % (path, urllib.urlencode(params))
        return self._call_astakos(path, method="GET", log_body=False)

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

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

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

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

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

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

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

565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582
    # ----------------------------------
    # do a GET to ``API_SERVICE_PROJECT_QUOTAS``
    def service_get_project_quotas(self, project=None):
        """Get all project quotas for resources associated with the service

        Keyword arguments:
        project    -- optionally, the uuid of a specific project

        In case of success return a dict of dicts with current quotas
        for all projects, or of a specified project, if project argument is set.
        Otherwise raise an AstakosClientException

        """
        query = self.api_service_project_quotas
        if project is not None:
            query += "?project=" + project
        return self._call_astakos(query)

583
    # ----------------------------------
584
    # do a POST to ``API_COMMISSIONS``
585
    def _issue_commission(self, request):
586 587 588 589 590 591 592 593 594 595
        """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'}
596
        req_body = parse_request(request, self.logger)
597
        try:
598 599 600 601
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
602 603 604 605 606 607 608 609 610
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
611 612 613 614
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
615

616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666
    def _mk_user_provision(self, holder, source, resource, quantity):
        holder = "user:" + holder
        source = "project:" + source
        return {"holder": holder, "source": source,
                "resource": resource, "quantity": quantity}

    def _mk_project_provision(self, holder, resource, quantity):
        holder = "project:" + holder
        return {"holder": holder, "source": None,
                "resource": resource, "quantity": quantity}

    def mk_provisions(self, holder, source, resource, quantity):
        return [self._mk_user_provision(holder, source, resource, quantity),
                self._mk_project_provision(source, resource, quantity)]

    def issue_commission_generic(self, user_provisions, project_provisions,
                                 name="", force=False, auto_accept=False):
        """Issue commission (for multiple holder/source pairs)

        keyword arguments:
        user_provisions  -- dict mapping user holdings
                            (user, project, resource) to integer quantities
        project_provisions -- dict mapping project holdings
                              (project, resource) to integer quantities
        name        -- description of the commission (string)
        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.

        """
        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
        request["name"] = name
        try:
            request["provisions"] = []
            for (holder, source, resource), quantity in \
                    user_provisions.iteritems():
                p = self._mk_user_provision(holder, source, resource, quantity)
                request["provisions"].append(p)
            for (holder, resource), quantity in project_provisions.iteritems():
                p = self._mk_project_provision(holder, resource, quantity)
                request["provisions"].append(p)
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

        return self._issue_commission(request)

667
    def issue_one_commission(self, holder, source, provisions,
668
                             name="", force=False, auto_accept=False):
669 670 671 672 673
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
674
        provisions  -- resources with their quantity (dict from string to int)
675
        name        -- description of the commission (string)
676 677 678 679 680 681 682
        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.

        """
683 684 685
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
686 687 688 689

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
690
        request["name"] = name
691 692
        try:
            request["provisions"] = []
693
            for resource, quantity in provisions.iteritems():
694 695
                ps = self.mk_provisions(holder, source, resource, quantity)
                request["provisions"].extend(ps)
696 697 698 699
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

700
        return self._issue_commission(request)
701

702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725
    def issue_resource_reassignment(self, holder, from_source,
                                    to_source, provisions, name="",
                                    force=False, auto_accept=False):
        """Change resource assignment to another project
        """

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
        request["name"] = name

        try:
            request["provisions"] = []
            for resource, quantity in provisions.iteritems():
                ps = self.mk_provisions(
                    holder, from_source, resource, -quantity)
                ps += self.mk_provisions(holder, to_source, resource, quantity)
                request["provisions"].extend(ps)
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

        return self._issue_commission(request)

726
    # ----------------------------------
727
    # do a GET to ``API_COMMISSIONS``
728
    def get_pending_commissions(self):
729 730 731 732 733 734
        """Get Pending Commissions

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

        """
735
        return self._call_astakos(self.api_commissions)
736

737
    # ----------------------------------
738
    # do a GET to ``API_COMMISSIONS``/<serial>
739
    def get_commission_info(self, serial):
740 741 742 743 744 745
        """Get Description of a Commission

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

        In case of success return a dict of dicts containing
746
        informations (details) about the requested commission
747 748

        """
749
        check_input("get_commission_info", self.logger, serial=serial)
750

751 752
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
753

754
    # ----------------------------------
755
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
756
    def commission_action(self, serial, action):
757
        """Perform a commission action
758 759 760 761 762 763 764 765

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

        In case of success return nothing.

        """
766 767
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
768

769
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
770
        req_headers = {'content-type': 'application/json'}
771
        req_body = parse_request({str(action): ""}, self.logger)
772 773
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
774

775
    def accept_commission(self, serial):
776
        """Accept a commission (see commission_action)"""
777
        self.commission_action(serial, "accept")
778

779
    def reject_commission(self, serial):
780
        """Reject a commission (see commission_action)"""
781
        self.commission_action(serial, "reject")
782

783
    # ----------------------------------
784
    # do a POST to ``API_COMMISSIONS_ACTION``
785
    def resolve_commissions(self, accept_serials, reject_serials):
786 787 788 789 790 791 792 793 794 795 796
        """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.

        """
797 798 799
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
800 801 802 803 804

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

809 810
    # ----------------------------
    # do a GET to ``API_PROJECTS``
811
    def get_projects(self, name=None, state=None, owner=None):
812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830
        """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)
831 832
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
833 834 835

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
836
    def get_project(self, project_id):
837 838 839 840 841 842 843
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
844 845
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
846 847 848

    # -----------------------------
    # do a POST to ``API_PROJECTS``
849
    def create_project(self, specs):
850 851 852 853 854 855 856 857 858
        """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)
859 860 861
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
862 863 864

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
865
    def modify_project(self, project_id, specs):
866 867 868 869 870 871 872 873
        """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.
        """
874
        path = join_urls(self.api_projects, str(project_id))
875 876
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
877 878
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
879 880 881

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
882
    def project_action(self, project_id, action, reason=""):
883 884 885 886 887 888 889 890 891 892
        """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.
        """
893
        path = join_urls(self.api_projects, str(project_id))
894 895 896
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
897 898
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
899 900 901

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
902
    def get_applications(self, project=None):
903 904 905 906 907 908 909 910 911 912
        """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
913 914
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
915 916 917

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
918
    def get_application(self, app_id):
919 920 921 922 923 924 925
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
926 927
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
928 929 930

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
931
    def application_action(self, app_id, action, reason=""):
932 933 934 935 936 937 938 939 940 941
        """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.
        """
942
        path = join_urls(self.api_applications, str(app_id))
943 944 945
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
946 947
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
948 949 950

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
951
    def get_memberships(self, project=None):
952 953 954 955 956 957 958 959 960 961
        """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
962 963
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
964 965 966

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
967
    def get_membership(self, memb_id):
968 969 970 971 972 973 974
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
975 976
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
977 978 979

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
980
    def membership_action(self, memb_id, action, reason=""):
981 982 983 984 985 986 987 988 989 990
        """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.
        """
991
        path = join_urls(self.api_memberships, str(memb_id))
992 993 994
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
995 996
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
997 998 999

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
1000
    def join_project(self, project_id):
1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
        """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)
1011 1012
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
1013 1014 1015

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
1016
    def enroll_member(self, project_id, email):
1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027
        """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)
1028 1029 1030
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")

1031
    # --------------------------------
1032
    # do a POST to ``API_OAUTH2_TOKEN``
1033
    def get_token(self, grant_type, client_id, client_secret, **body_params):
1034
        headers = {'content-type': 'application/x-www-form-urlencoded',
1035 1036 1037 1038 1039
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
                                                           (client_id,
                                                            client_secret))}
        body_params['grant_type'] = grant_type
        body = urllib.urlencode(