__init__.py 21.6 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

import logging
import urlparse
36
import urllib
37
import hashlib
38
from copy import copy
39 40

import simplejson
41 42
from astakosclient.utils import \
    retry, scheme_to_class, parse_request, check_input
43
from astakosclient.errors import \
44
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
45
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse
46 47


48 49 50 51 52 53 54 55 56 57 58
# --------------------------------------------------------------------
# Astakos API urls
API_AUTHENTICATE = "/astakos/api/authenticate"
API_USERCATALOGS = "/astakos/api/user_catalogs"
API_SERVICE_USERCATALOGS = "/astakos/api/service/user_catalogs"
API_GETSERVICES = "/astakos/api/get_services"
API_RESOURCES = "/astakos/api/resources"
API_QUOTAS = "/astakos/api/quotas"
API_SERVICE_QUOTAS = "/astakos/api/service_quotas"
API_COMMISSIONS = "/astakos/api/commissions"
API_COMMISSIONS_ACTION = API_COMMISSIONS + "/action"
59
API_FEEDBACK = "/astakos/api/feedback"
60 61
API_TOKENS = "/astakos/api/tokens"
TOKENS_ENDPOINTS = "endpoints"
62 63


64
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
65
# Astakos Client Class
66

67
def get_token_from_cookie(request, cookie_name):
68 69 70 71 72 73 74 75 76 77 78 79 80 81
    """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]
    except:
        return None


Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
82 83 84 85
class AstakosClient():
    """AstakosClient Class Implementation"""

    # ----------------------------------
86 87
    def __init__(self, astakos_url, retry=0,
                 use_pool=False, pool_size=8, logger=None):
88
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
89 90 91 92

        Keyword arguments:
        astakos_url -- i.e https://accounts.example.com (string)
        use_pool    -- use objpool for http requests (boolean)
93
        retry       -- how many time to retry (integer)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
94 95 96 97
        logger      -- pass a different logger

        """
        if logger is None:
98 99 100 101
            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
102
            logger = logging.getLogger("astakosclient")
103
        logger.debug("Intialize AstakosClient: astakos_url = %s, "
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
104 105
                     "use_pool = %s" % (astakos_url, use_pool))

106
        check_input("__init__", logger, astakos_url=astakos_url)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
107 108 109

        # Check for supported scheme
        p = urlparse.urlparse(astakos_url)
110
        conn_class = scheme_to_class(p.scheme, use_pool, pool_size)
111
        if conn_class is None:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
112 113
            m = "Unsupported scheme: %s" % p.scheme
            logger.error(m)
114
            raise BadValue(m)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
115

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
116
        # Save astakos_url etc. in our class
117
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
118 119 120
        self.logger = logger
        self.netloc = p.netloc
        self.scheme = p.scheme
121
        self.path = p.path.rstrip('/')
122
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
123

124 125
    # ----------------------------------
    @retry
126 127
    def _call_astakos(self, token, request_path,
                      headers=None, body=None, method="GET"):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
128
        """Make the actual call to Astakos Service"""
129 130 131 132 133 134
        if token is not None:
            hashed_token = hashlib.sha1()
            hashed_token.update(token)
            using_token = "using token %s" % (hashed_token.hexdigest())
        else:
            using_token = "without using token"
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
135
        self.logger.debug(
136 137
            "Make a %s request to %s %s with headers %s and body %s"
            % (method, request_path, using_token, headers, body))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
138

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
139
        # Check Input
140 141 142 143
        if headers is None:
            headers = {}
        if body is None:
            body = {}
144
        path = self.path + "/" + request_path.strip('/')
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
145

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
146 147
        # Build request's header and body
        kwargs = {}
148
        kwargs['headers'] = copy(headers)
149 150
        if token is not None:
            kwargs['headers']['X-Auth-Token'] = token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
151
        if body:
152
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
153 154 155 156 157 158
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
159 160 161
            # Get the connection object
            with self.conn_class(self.netloc) as conn:
                # Send request
162
                (message, data, status) = \
163
                    _do_request(conn, method, path, **kwargs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
164
        except Exception as err:
165
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
166 167 168 169
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
170
        if status == 400:
171
            raise BadRequest(message, data)
172
        elif status == 401:
173
            raise Unauthorized(message, data)
174
        elif status == 403:
175
            raise Forbidden(message, data)
176
        elif status == 404:
177
            raise NotFound(message, data)
178
        elif status < 200 or status >= 300:
179
            raise AstakosClientException(message, data, status)
180 181 182 183 184

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
185
                return None
186
        except Exception as err:
187 188
            self.logger.error("Cannot parse response \"%s\" with simplejson: %s"
                              % (data, str(err)))
189
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
190 191

    # ------------------------
192
    # do a GET to ``API_AUTHENTICATE``
193
    def get_user_info(self, token, usage=False):
194
        """Authenticate user and get user's info as a dictionary
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
195 196

        Keyword arguments:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
197
        token   -- user's token (string)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
198 199 200 201 202 203
        usage   -- return usage information for user (boolean)

        In case of success return user information (json parsed format).
        Otherwise raise an AstakosClientException.

        """
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
204
        # Send request
205
        auth_path = copy(API_AUTHENTICATE)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
206 207
        if usage:
            auth_path += "?usage=1"
208
        return self._call_astakos(token, auth_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
209 210

    # ----------------------------------
211
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
212
    #   with {'uuids': uuids}
213
    def _uuid_catalog(self, token, uuids, req_path):
214
        req_headers = {'content-type': 'application/json'}
215
        req_body = parse_request({'uuids': uuids}, self.logger)
216
        data = self._call_astakos(
217
            token, req_path, req_headers, req_body, "POST")
218 219 220
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
221
            m = "_uuid_catalog request returned %s. No uuid_catalog found" \
222 223 224
                % data
            self.logger.error(m)
            raise AstakosClientException(m)
225

226
    def get_usernames(self, token, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
227 228 229
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
230
        token   -- user's token (string)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
231 232 233 234 235 236
        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

        """
237
        req_path = copy(API_USERCATALOGS)
238
        return self._uuid_catalog(token, uuids, req_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
239

240 241
    def get_username(self, token, uuid):
        """Return the user name of a uuid (see get_usernames)"""
242
        check_input("get_username", self.logger, uuid=uuid)
243
        uuid_dict = self.get_usernames(token, [uuid])
244 245 246
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
247
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
248

249
    def service_get_usernames(self, token, uuids):
250
        """Return a uuid_catalog dict using a service's token"""
251
        req_path = copy(API_SERVICE_USERCATALOGS)
252
        return self._uuid_catalog(token, uuids, req_path)
253

254
    def service_get_username(self, token, uuid):
255
        """Return the displayName of a uuid using a service's token"""
256
        check_input("service_get_username", self.logger, uuid=uuid)
257
        uuid_dict = self.service_get_usernames(token, [uuid])
258 259 260
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
261
            raise NoUserName(uuid)
262

263
    # ----------------------------------
264
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
265
    #   with {'displaynames': display_names}
266
    def _displayname_catalog(self, token, display_names, req_path):
267
        req_headers = {'content-type': 'application/json'}
268
        req_body = parse_request({'displaynames': display_names}, self.logger)
269
        data = self._call_astakos(
270
            token, req_path, req_headers, req_body, "POST")
271 272 273
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
274
            m = "_displayname_catalog request returned %s. " \
275 276 277
                "No displayname_catalog found" % data
            self.logger.error(m)
            raise AstakosClientException(m)
278

279
    def get_uuids(self, token, display_names):
280 281 282 283 284 285 286 287 288 289
        """Return a displayname_catalog for the given names

        Keyword arguments:
        token           -- user's token (string)
        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

        """
290
        req_path = copy(API_USERCATALOGS)
291
        return self._displayname_catalog(token, display_names, req_path)
292

293
    def get_uuid(self, token, display_name):
294
        """Return the uuid of a name (see getUUIDs)"""
295
        check_input("get_uuid", self.logger, display_name=display_name)
296
        name_dict = self.get_uuids(token, [display_name])
297 298 299 300
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
301

302
    def service_get_uuids(self, token, display_names):
303
        """Return a display_name catalog using a service's token"""
304
        req_path = copy(API_SERVICE_USERCATALOGS)
305
        return self._displayname_catalog(token, display_names, req_path)
306

307
    def service_get_uuid(self, token, display_name):
308
        """Return the uuid of a name using a service's token"""
309
        check_input("service_get_uuid", self.logger, display_name=display_name)
310
        name_dict = self.service_get_uuids(token, [display_name])
311 312 313 314
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
315

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
316
    # ----------------------------------
317
    # do a GET to ``API_GETSERVICES``
318
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
319
        """Return a list of dicts with the registered services"""
320
        return self._call_astakos(None, copy(API_GETSERVICES))
321 322

    # ----------------------------------
323
    # do a GET to ``API_RESOURCES``
324 325
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
326
        return self._call_astakos(None, copy(API_RESOURCES))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
327

328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
    def send_feedback(self, token, message, data):
        """Send feedback to astakos service

        keyword arguments:
        token       -- user's token (string)
        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)
        path = copy(API_FEEDBACK)
        req_body = urllib.urlencode(
            {'feedback_msg': message, 'feedback_data': data})
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
        self._call_astakos(token, path, None, req_body, "POST")

    # ----------------------------------
    # do a GET to ``API_TOKENS``/<user_token>/``TOKENS_ENDPOINTS``
    def get_endpoints(self, token, belongs_to=None, marker=None, limit=None):
        """Request registered endpoints from astakos

        keyword arguments:
        token       -- user's token (string)
        belongs_to  -- user's uuid (string)
        marker      -- return endpoints whose ID is higher than marker's (int)
        limit       -- maximum number of endpoints to return (int)

        Return a json formatted dictionary containing information
        about registered endpoints.

        WARNING: This api call encodes the user's token inside the url.
        It's thoughs security unsafe to use it (both astakosclient and
        nginx tend to log requested urls).
        Avoid the use of get_endpoints method and use *** instead.

        """
        params = {}
        if belongs_to is not None:
            params['belongsTo'] = str(belongs_to)
        if marker is not None:
            params['marker'] = str(marker)
        if limit is not None:
            params['limit'] = str(limit)
        path = API_TOKENS + "/" + token + "/" + \
            TOKENS_ENDPOINTS + "?" + urllib.urlencode(params)
        return self._call_astakos(token, path)
378

379
    # ----------------------------------
380
    # do a GET to ``API_QUOTAS``
381
    def get_quotas(self, token):
382 383 384 385 386 387 388 389 390
        """Get user's quotas

        Keyword arguments:
        token   -- user's token (string)

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

        """
391
        return self._call_astakos(token, copy(API_QUOTAS))
392

393
    # ----------------------------------
394
    # do a GET to ``API_SERVICE_QUOTAS``
395
    def service_get_quotas(self, token, user=None):
396 397 398 399
        """Get all quotas for resources associated with the service

        Keyword arguments:
        token   -- service's token (string)
400
        user    -- optionally, the uuid of a specific user
401 402

        In case of success return a dict of dicts of dicts with current quotas
403
        for all users, or of a specified user, if user argument is set.
404 405 406
        Otherwise raise an AstakosClientException

        """
407
        query = copy(API_SERVICE_QUOTAS)
408 409 410
        if user is not None:
            query += "?user=" + user
        return self._call_astakos(token, query)
411

412
    # ----------------------------------
413
    # do a POST to ``API_COMMISSIONS``
414 415 416 417
    def issue_commission(self, token, request):
        """Issue a commission

        Keyword arguments:
418
        token   -- service's token (string)
419 420 421 422 423 424 425
        request -- commision request (dict)

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

        """
        req_headers = {'content-type': 'application/json'}
426
        req_body = parse_request(request, self.logger)
427
        try:
428
            response = self._call_astakos(token, copy(API_COMMISSIONS),
429 430 431 432 433 434 435 436 437 438 439 440 441 442 443
                                          req_headers, req_body, "POST")
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
            m = "issue_commission_core request returned %s. No serial found" \
                % response
            self.logger.error(m)
            raise AstakosClientException(m)

444
    def issue_one_commission(self, token, holder, source, provisions,
445
                             name="", force=False, auto_accept=False):
446 447 448 449 450 451
        """Issue one commission (with specific holder and source)

        keyword arguments:
        token       -- service's token (string)
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
452
        provisions  -- resources with their quantity (dict from string to int)
453
        name        -- description of the commission (string)
454 455 456 457 458 459 460 461
        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)

        """
462 463 464
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
465 466 467 468

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
469
        request["name"] = name
470 471
        try:
            request["provisions"] = []
472
            for resource, quantity in provisions.iteritems():
473 474 475 476 477 478 479 480 481
                t = {"holder": holder, "source": source,
                     "resource": resource, "quantity": quantity}
                request["provisions"].append(t)
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

        return self.issue_commission(token, request)

482
    # ----------------------------------
483
    # do a GET to ``API_COMMISSIONS``
484 485 486 487
    def get_pending_commissions(self, token):
        """Get Pending Commissions

        Keyword arguments:
488
        token   -- service's token (string)
489 490 491 492 493

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

        """
494
        return self._call_astakos(token, copy(API_COMMISSIONS))
495

496
    # ----------------------------------
497
    # do a GET to ``API_COMMISSIONS``/<serial>
498 499 500 501
    def get_commission_info(self, token, serial):
        """Get Description of a Commission

        Keyword arguments:
502
        token   -- service's token (string)
503 504 505
        serial  -- commission's id (int)

        In case of success return a dict of dicts containing
506
        informations (details) about the requested commission
507 508

        """
509
        check_input("get_commission_info", self.logger, serial=serial)
510

511
        path = API_COMMISSIONS + "/" + str(serial)
512 513
        return self._call_astakos(token, path)

514
    # ----------------------------------
515
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
516 517
    def commission_action(self, token, serial, action):
        """Perform a commission action
518 519

        Keyword arguments:
520
        token   -- service's token (string)
521 522 523 524 525 526
        serial  -- commission's id (int)
        action  -- action to perform, currently accept/reject (string)

        In case of success return nothing.

        """
527 528
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
529

530
        path = API_COMMISSIONS + "/" + str(serial) + "/action"
531
        req_headers = {'content-type': 'application/json'}
532
        req_body = parse_request({str(action): ""}, self.logger)
533 534
        self._call_astakos(token, path, req_headers, req_body, "POST")

535 536 537
    def accept_commission(self, token, serial):
        """Accept a commission (see commission_action)"""
        self.commission_action(token, serial, "accept")
538

539 540 541
    def reject_commission(self, token, serial):
        """Reject a commission (see commission_action)"""
        self.commission_action(token, serial, "reject")
542

543
    # ----------------------------------
544
    # do a POST to ``API_COMMISSIONS_ACTION``
545 546 547 548 549 550 551 552 553 554 555 556 557
    def resolve_commissions(self, token, accept_serials, reject_serials):
        """Resolve multiple commissions at once

        Keyword arguments:
        token           -- service's token (string)
        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.

        """
558 559 560
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
561

562
        path = copy(API_COMMISSIONS_ACTION)
563 564 565 566 567 568
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
        return self._call_astakos(token, path, req_headers, req_body, "POST")

569

570 571
# --------------------------------------------------------------------
# Private functions
572 573
# We want _doRequest to be a distinct function
# so that we can replace it during unit tests.
574
def _do_request(conn, method, url, **kwargs):
575 576 577 578 579 580
    """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)
581 582
    message = response.reason
    return (message, data, status)