Cross-account operations in backend/API/docs.
authorAntony Chazapis <chazapis@gmail.com>
Tue, 19 Jul 2011 14:03:25 +0000 (17:03 +0300)
committerAntony Chazapis <chazapis@gmail.com>
Tue, 19 Jul 2011 14:03:25 +0000 (17:03 +0300)
Refs #763

docs/source/client-lib.rst
docs/source/devguide.rst
pithos/api/functions.py
pithos/api/util.py
pithos/backends/base.py
pithos/backends/simple.py

index d1a2fbc..812a74d 100644 (file)
@@ -1,4 +1,4 @@
 Client Library
-=============
+==============
 
-.. automodule:: pithos.lib.client
\ No newline at end of file
+.. automodule:: pithos.lib.client
index 9a2c5f1..854ebfb 100644 (file)
@@ -25,10 +25,11 @@ Document Revisions
 =========================  ================================
 Revision                   Description
 =========================  ================================
-0.5 (July 16, 2011)        Object update from another object's data.
+0.5 (July 19, 2011)        Object update from another object's data.
 \                          Support object truncate.
 \                          Create object using a standard HTML form.
 \                          Purge container/object history.
+\                          List other accounts that share objects with a user.
 0.4 (July 01, 2011)        Object permissions and account groups.
 \                          Control versioning behavior and container quotas with container policy directives.
 \                          Support updating/deleting individual metadata with ``POST``.
@@ -77,7 +78,7 @@ List of operations:
 =========  ==================
 Operation  Description
 =========  ==================
-GET        Authentication. This is kept for compatibility with the OOS API
+GET        Authentication (for compatibility with the OOS API) or list allowed accounts
 =========  ==================
 
 GET
@@ -91,6 +92,54 @@ Return Code       Description
 204 (No Content)  The request succeeded
 ================  =====================
 
+If an ``X-Auth-Token`` is already present, the operation will be interpreted as a request to list other accounts that share objects to the user.
+
+======================  =========================
+Request Parameter Name  Value
+======================  =========================
+limit                   The amount of results requested (default is 10000)
+marker                  Return containers with name lexicographically after marker
+format                  Optional extended reply type (can be ``json`` or ``xml``)
+======================  =========================
+
+The reply is a list of account names.
+If a ``format=xml`` or ``format=json`` argument is given, extended information on the containers will be returned, serialized in the chosen format.
+For each account, the information will include the following (names will be in lower case and with hyphens replaced with underscores):
+
+===========================  ============================
+Name                         Description
+===========================  ============================
+name                         The name of the account
+last_modified                The last container modification date (regardless of ``until``)
+===========================  ============================
+
+Example ``format=json`` reply:
+
+::
+
+  [{"name": "user", "last_modified": "2011-07-19T10:48:16"}, ...]
+
+Example ``format=xml`` reply:
+
+::
+
+  <?xml version="1.0" encoding="UTF-8"?>
+  <accounts>
+    <account>
+      <name>user</name>
+      <last_modified>2011-07-19T10:48:16</last_modified>
+    </account>
+    <account>...</account>
+  </accounts>
+
+===========================  =====================
+Return Code                  Description
+===========================  =====================
+200 (OK)                     The request succeeded
+204 (No Content)             The account has no containers (only for non-extended replies)
+===========================  =====================
+
+Will use a ``200`` return code if the reply is of type json/xml.
 
 Account Level
 ^^^^^^^^^^^^^
@@ -114,7 +163,7 @@ Request Parameter Name  Value
 until                   Optional timestamp
 ======================  ===================================
 
-|
+Cross-user requests are not allowed to use ``until`` and only include the account modification date in the reply.
 
 ==========================  =====================
 Reply Header Name           Value
@@ -161,6 +210,8 @@ until                   Optional timestamp
 ======================  =========================
 
 The reply is a list of container names. Account headers (as in a ``HEAD`` request) will also be included.
+Cross-user requests are not allowed to use ``until`` and only include the account/container modification dates in the reply.
+
 If a ``format=xml`` or ``format=json`` argument is given, extended information on the containers will be returned, serialized in the chosen format.
 For each container, the information will include all container metadata (names will be in lower case and with hyphens replaced with underscores):
 
@@ -245,7 +296,7 @@ Request Parameter Name  Value
 until                   Optional timestamp
 ======================  ===================================
 
-|
+Cross-user requests are not allowed to use ``until`` and only include the container modification date in the reply.
 
 ===========================  ===============================
 Reply Header Name            Value
@@ -300,6 +351,17 @@ The ``path`` parameter overrides ``prefix`` and ``delimiter``. When using ``path
 The keys given with ``meta`` will be matched with the strings after the ``X-Object-Meta-`` prefix.
 
 The reply is a list of object names. Container headers (as in a ``HEAD`` request) will also be included.
+Cross-user requests are not allowed to use ``until`` and include the following limited set of headers in the reply:
+
+===========================  ===============================
+Reply Header Name            Value
+===========================  ===============================
+X-Container-Block-Size       The block size used by the storage backend
+X-Container-Block-Hash       The hash algorithm used for block identifiers in object hashmaps
+X-Container-Object-Meta      A list with all meta keys used by allowed objects (**TBD**)
+Last-Modified                The last container modification date
+===========================  ===============================
+
 If a ``format=xml`` or ``format=json`` argument is given, extended information on the objects will be returned, serialized in the chosen format.
 For each object, the information will include all object metadata (names will be in lower case and with hyphens replaced with underscores):
 
@@ -791,7 +853,9 @@ Sharing and Public Objects
 
 Read and write control in Pithos is managed by setting appropriate permissions with the ``X-Object-Sharing`` header. The permissions are applied using prefix-based inheritance. Thus, each set of authorization directives is applied to all objects sharing the same prefix with the object where the corresponding ``X-Object-Sharing`` header is defined. For simplicity, nested/overlapping permissions are not allowed. Setting ``X-Object-Sharing`` will fail, if the object is already "covered", or another object with a longer common-prefix name already has permissions. When retrieving an object, the ``X-Object-Shared-By`` header reports where it gets its permissions from. If not present, the object is the actual source of authorization directives.
 
-Objects that are marked as public, via the ``X-Object-Public`` meta, are also available at the corresponding URI returned for ``HEAD`` or ``GET``. Requests for public objects do not need to include an ``X-Auth-Token``. Pithos will ignore request parameters and only include the following headers in the reply (all ``X-Object-*`` meta is hidden).
+A user may ``GET`` another account or container. The result will include a limited reply, containing only the allowed containers or objects respectively. A top-level request with an authentication token, will return a list of allowed accounts, so the user can easily find out which other users share objects.
+
+Objects that are marked as public, via the ``X-Object-Public`` meta, are also available at the corresponding URI returned for ``HEAD`` or ``GET``. Requests for public objects do not need to include an ``X-Auth-Token``. Pithos will ignore request parameters and only include the following headers in the reply (all ``X-Object-*`` meta is hidden):
 
 ==========================  ===============================
 Reply Header Name           Value
@@ -805,6 +869,8 @@ Content-Encoding            The encoding of the object (optional)
 Content-Disposition         The presentation style of the object (optional)
 ==========================  ===============================
 
+Public objects are not included and do not influence cross-user listings. They are, however, readable by all users.
+
 Summary
 ^^^^^^^
 
@@ -827,7 +893,7 @@ List of differences from the OOS API:
 * Object ``MOVE`` support.
 * Time-variant account/container listings via the ``until`` parameter.
 * Object versions - parameter ``version`` in ``HEAD``/``GET`` (list versions with ``GET``), ``X-Object-Version-*`` meta in replies, ``X-Source-Version`` in ``PUT``/``COPY``.
-* Sharing/publishing with ``X-Object-Sharing``, ``X-Object-Public`` at the object level. Permissions may include groups defined with ``X-Account-Group-*`` at the account level. These apply to the object - not its versions.
+* Sharing/publishing with ``X-Object-Sharing``, ``X-Object-Public`` at the object level. Cross-user operations are allowed - controlled by sharing directives. Permissions may include groups defined with ``X-Account-Group-*`` at the account level. These apply to the object - not its versions.
 * Support for prefix-based inheritance when enforcing permissions. Parent object carrying the authorization directives is reported in ``X-Object-Shared-By``.
 * Large object support with ``X-Object-Manifest``.
 * Trace the user that created/modified an object with ``X-Object-Modified-By``.
index 12b719c..abad451 100644 (file)
@@ -59,6 +59,8 @@ logger = logging.getLogger(__name__)
 
 def top_demux(request):
     if request.method == 'GET':
+        if request.user:
+            return account_list(request)
         return authenticate(request)
     else:
         return method_not_allowed(request)
@@ -125,6 +127,49 @@ def authenticate(request):
                                             x_auth_user)
     return response
 
+@api_method('GET', format_allowed=True)
+def account_list(request):
+    # Normal Response Codes: 200, 204
+    # Error Response Codes: serviceUnavailable (503),
+    #                       badRequest (400)
+    
+    response = HttpResponse()
+    
+    marker = request.GET.get('marker')
+    limit = get_int_parameter(request.GET.get('limit'))
+    if not limit:
+        limit = 10000
+    
+    accounts = backend.list_accounts(request.user, marker, limit)
+    
+    if request.serialization == 'text':
+        if len(accounts) == 0:
+            # The cloudfiles python bindings expect 200 if json/xml.
+            response.status_code = 204
+            return response
+        response.status_code = 200
+        response.content = '\n'.join(accounts) + '\n'
+        return response
+    
+    account_meta = []
+    for x in accounts:
+        try:
+            meta = backend.get_account_meta(request.user, x)
+            groups = backend.get_account_groups(request.user, x)
+        except NotAllowedError:
+            raise Unauthorized('Access denied')
+        else:
+            for k, v in groups.iteritems():
+                meta['X-Container-Group-' + k] = ','.join(v)
+            account_meta.append(printable_header_dict(meta))
+    if request.serialization == 'xml':
+        data = render_to_string('accounts.xml', {'accounts': account_meta})
+    elif request.serialization  == 'json':
+        data = json.dumps(account_meta)
+    response.status_code = 200
+    response.content = data
+    return response
+
 @api_method('HEAD')
 def account_meta(request, v_account):
     # Normal Response Codes: 204
@@ -188,14 +233,9 @@ def container_list(request, v_account):
     put_account_headers(response, meta, groups)
     
     marker = request.GET.get('marker')
-    limit = request.GET.get('limit')
-    if limit:
-        try:
-            limit = int(limit)
-            if limit <= 0:
-                raise ValueError
-        except ValueError:
-            limit = 10000
+    limit = get_int_parameter(request.GET.get('limit'))
+    if not limit:
+        limit = 10000
     
     try:
         containers = backend.list_containers(request.user, v_account, marker, limit, until)
@@ -210,23 +250,22 @@ def container_list(request, v_account):
             response.status_code = 204
             return response
         response.status_code = 200
-        response.content = '\n'.join([x[0] for x in containers]) + '\n'
+        response.content = '\n'.join(containers) + '\n'
         return response
     
     container_meta = []
     for x in containers:
-        if x[1] is not None:
-            try:
-                meta = backend.get_container_meta(request.user, v_account, x[0], until)
-                policy = backend.get_container_policy(request.user, v_account, x[0])
-            except NotAllowedError:
-                raise Unauthorized('Access denied')
-            except NameError:
-                pass
-            else:
-                for k, v in policy.iteritems():
-                    meta['X-Container-Policy-' + k] = v
-                container_meta.append(printable_header_dict(meta))
+        try:
+            meta = backend.get_container_meta(request.user, v_account, x, until)
+            policy = backend.get_container_policy(request.user, v_account, x)
+        except NotAllowedError:
+            raise Unauthorized('Access denied')
+        except NameError:
+            pass
+        else:
+            for k, v in policy.iteritems():
+                meta['X-Container-Policy-' + k] = v
+            container_meta.append(printable_header_dict(meta))
     if request.serialization == 'xml':
         data = render_to_string('containers.xml', {'account': v_account, 'containers': container_meta})
     elif request.serialization  == 'json':
@@ -376,14 +415,9 @@ def object_list(request, v_account, v_container):
     prefix = prefix.lstrip('/')
     
     marker = request.GET.get('marker')
-    limit = request.GET.get('limit')
-    if limit:
-        try:
-            limit = int(limit)
-            if limit <= 0:
-                raise ValueError
-        except ValueError:
-            limit = 10000
+    limit = get_int_parameter(request.GET.get('limit'))
+    if not limit:
+        limit = 10000
     
     keys = request.GET.get('meta')
     if keys:
index e3f1086..f0133d0 100644 (file)
@@ -96,8 +96,10 @@ def get_account_headers(request):
     return meta, groups
 
 def put_account_headers(response, meta, groups):
-    response['X-Account-Container-Count'] = meta['count']
-    response['X-Account-Bytes-Used'] = meta['bytes']
+    if 'count' in meta:
+        response['X-Account-Container-Count'] = meta['count']
+    if 'bytes' in meta:
+        response['X-Account-Bytes-Used'] = meta['bytes']
     if 'modified' in meta:
         response['Last-Modified'] = http_date(int(meta['modified']))
     for k in [x for x in meta.keys() if x.startswith('X-Account-Meta-')]:
@@ -113,8 +115,10 @@ def get_container_headers(request):
     return meta, policy
 
 def put_container_headers(response, meta, policy):
-    response['X-Container-Object-Count'] = meta['count']
-    response['X-Container-Bytes-Used'] = meta['bytes']
+    if 'count' in meta:
+        response['X-Container-Object-Count'] = meta['count']
+    if 'bytes' in meta:
+        response['X-Container-Bytes-Used'] = meta['bytes']
     response['Last-Modified'] = http_date(int(meta['modified']))
     for k in [x for x in meta.keys() if x.startswith('X-Container-Meta-')]:
         response[k.encode('utf-8')] = meta[k].encode('utf-8')
index 004e790..fd36289 100644 (file)
@@ -49,6 +49,15 @@ class BaseBackend(object):
         'block_size': Suggested is 4MB
     """
     
+    def list_accounts(self, user, marker=None, limit=10000):
+        """Return a list of accounts the user can access.
+        
+        Parameters:
+            'marker': Start list from the next item after 'marker'
+            'limit': Number of containers to return
+        """
+        return []
+    
     def get_account_meta(self, user, account, until=None):
         """Return a dictionary with the account metadata.
         
@@ -103,7 +112,7 @@ class BaseBackend(object):
         return
     
     def list_containers(self, user, account, marker=None, limit=10000, until=None):
-        """Return a list of container (name, version_id) tuples existing under an account.
+        """Return a list of container names existing under an account.
         
         Parameters:
             'marker': Start list from the next item after 'marker'
index 4970052..7964893 100644 (file)
@@ -130,12 +130,21 @@ class SimpleBackend(BaseBackend):
         self.mapper = Mapper(**params)
     
     @backend_method
+    def list_accounts(self, user, marker=None, limit=10000):
+        """Return a list of accounts the user can access."""
+        
+        allowed = self._allowed_accounts(user)
+        start, limit = self._list_limits(allowed, marker, limit)
+        return allowed[start:start + limit]
+    
+    @backend_method
     def get_account_meta(self, user, account, until=None):
         """Return a dictionary with the account metadata."""
         
         logger.debug("get_account_meta: %s %s", account, until)
         if user != account:
-            raise NotAllowedError
+            if until or account not in self._allowed_accounts(user):
+                raise NotAllowedError
         try:
             version_id, mtime = self._get_accountinfo(account, until)
         except NameError:
@@ -158,12 +167,15 @@ class SimpleBackend(BaseBackend):
         row = c.fetchone()
         count = row[0]
         
-        meta = self._get_metadata(account, version_id)
-        meta.update({'name': account, 'count': count, 'bytes': bytes})
+        if user != account:
+            meta = {'name': account}
+        else:
+            meta = self._get_metadata(account, version_id)
+            meta.update({'name': account, 'count': count, 'bytes': bytes})
+            if until is not None:
+                meta.update({'until_timestamp': tstamp})
         if modified:
             meta.update({'modified': modified})
-        if until is not None:
-            meta.update({'until_timestamp': tstamp})
         return meta
     
     @backend_method
@@ -181,7 +193,9 @@ class SimpleBackend(BaseBackend):
         
         logger.debug("get_account_groups: %s", account)
         if user != account:
-            raise NotAllowedError
+            if account not in self._allowed_accounts(user):
+                raise NotAllowedError
+            return {}
         return self._get_groups(account)
     
     @backend_method
@@ -229,19 +243,12 @@ class SimpleBackend(BaseBackend):
         
         logger.debug("list_containers: %s %s %s %s", account, marker, limit, until)
         if user != account:
-            if until:
+            if until or account not in self._allowed_accounts(user):
                 raise NotAllowedError
-            containers = self._allowed_containers(user, account)
-            start = 0
-            if marker:
-                try:
-                    start = containers.index(marker) + 1
-                except ValueError:
-                    pass
-            if not limit or limit > 10000:
-                limit = 10000
-            return containers[start:start + limit]
-        return self._list_objects(account, '', '/', marker, limit, False, [], until)
+            allowed = self._allowed_containers(user, account)
+            start, limit = self._list_limits(allowed, marker, limit)
+            return allowed[start:start + limit]
+        return [x[0] for x in self._list_objects(account, '', '/', marker, limit, False, [], until)]
     
     @backend_method
     def get_container_meta(self, user, account, container, until=None):
@@ -249,7 +256,8 @@ class SimpleBackend(BaseBackend):
         
         logger.debug("get_container_meta: %s %s %s", account, container, until)
         if user != account:
-            raise NotAllowedError
+            if until or container not in self._allowed_containers(user, account):
+                raise NotAllowedError
         path, version_id, mtime = self._get_containerinfo(account, container, until)
         count, bytes, tstamp = self._get_pathstats(path, until)
         if mtime > tstamp:
@@ -261,10 +269,13 @@ class SimpleBackend(BaseBackend):
             if mtime > modified:
                 modified = mtime
         
-        meta = self._get_metadata(path, version_id)
-        meta.update({'name': container, 'count': count, 'bytes': bytes, 'modified': modified})
-        if until is not None:
-            meta.update({'until_timestamp': tstamp})
+        if user != account:
+            meta = {'name': container, 'modified': modified}
+        else:
+            meta = self._get_metadata(path, version_id)
+            meta.update({'name': container, 'count': count, 'bytes': bytes, 'modified': modified})
+            if until is not None:
+                meta.update({'until_timestamp': tstamp})
         return meta
     
     @backend_method
@@ -283,7 +294,9 @@ class SimpleBackend(BaseBackend):
         
         logger.debug("get_container_policy: %s %s", account, container)
         if user != account:
-            raise NotAllowedError
+            if container not in self._allowed_containers(user, account):
+                raise NotAllowedError
+            return {}
         path = self._get_containerinfo(account, container)[0]
         return self._get_policy(path)
     
@@ -360,23 +373,38 @@ class SimpleBackend(BaseBackend):
         """Return a list of objects existing under a container."""
         
         logger.debug("list_objects: %s %s %s %s %s %s %s %s %s", account, container, prefix, delimiter, marker, limit, virtual, keys, until)
+        allowed = []
         if user != account:
-            raise NotAllowedError
+            if until:
+                raise NotAllowedError
+            allowed = self._allowed_paths(user, os.path.join(account, container))
+            if not allowed:
+                raise NotAllowedError
         path, version_id, mtime = self._get_containerinfo(account, container, until)
-        return self._list_objects(path, prefix, delimiter, marker, limit, virtual, keys, until)
+        return self._list_objects(path, prefix, delimiter, marker, limit, virtual, keys, until, allowed)
     
     @backend_method
     def list_object_meta(self, user, account, container, until=None):
         """Return a list with all the container's object meta keys."""
         
         logger.debug("list_object_meta: %s %s %s", account, container, until)
+        allowed = []
         if user != account:
-            raise NotAllowedError
+            if until:
+                raise NotAllowedError
+            allowed = self._allowed_paths(user, os.path.join(account, container))
+            if not allowed:
+                raise NotAllowedError
         path, version_id, mtime = self._get_containerinfo(account, container, until)
         sql = '''select distinct m.key from (%s) o, metadata m
                     where m.version_id = o.version_id and o.name like ?'''
         sql = sql % self._sql_until(until)
-        c = self.con.execute(sql, (path + '/%',))
+        param = (path + '/%',)
+        if allowed:
+            for x in allowed:
+                sql += ' and o.name like ?'
+                param += (x,)
+        c = self.con.execute(sql, param)
         return [x[0] for x in c.fetchall()]
     
     @backend_method
@@ -723,17 +751,39 @@ class SimpleBackend(BaseBackend):
         c = self.con.execute(sql, (path,))
         return dict(c.fetchall())
     
-    def _list_objects(self, path, prefix='', delimiter=None, marker=None, limit=10000, virtual=True, keys=[], until=None):
+    
+    def _list_limits(self, listing, marker, limit):
+        start = 0
+        if marker:
+            try:
+                start = listing.index(marker) + 1
+            except ValueError:
+                pass
+        if not limit or limit > 10000:
+            limit = 10000
+        return start, limit
+    
+    def _list_objects(self, path, prefix='', delimiter=None, marker=None, limit=10000, virtual=True, keys=[], until=None, allowed=[]):
         cont_prefix = path + '/'
         if keys and len(keys) > 0:
             sql = '''select distinct o.name, o.version_id from (%s) o, metadata m where o.name like ? and
-                        m.version_id = o.version_id and m.key in (%s) order by o.name'''
+                        m.version_id = o.version_id and m.key in (%s)'''
             sql = sql % (self._sql_until(until), ', '.join('?' * len(keys)))
             param = (cont_prefix + prefix + '%',) + tuple(keys)
+            if allowed:
+                for x in allowed:
+                    sql += ' and o.name like ?'
+                    param += (x,)
+            sql += ' order by o.name'
         else:
-            sql = 'select name, version_id from (%s) where name like ? order by name'
+            sql = 'select name, version_id from (%s) where name like ?'
             sql = sql % self._sql_until(until)
             param = (cont_prefix + prefix + '%',)
+            if allowed:
+                for x in allowed:
+                    sql += ' and name like ?'
+                    param += (x,)
+            sql += ' order by name'
         c = self.con.execute(sql, param)
         objects = [(x[0][len(cont_prefix):], x[1]) for x in c.fetchall()]
         if delimiter:
@@ -757,14 +807,7 @@ class SimpleBackend(BaseBackend):
                             pseudo_objects.append((pseudo_name, None))
             objects = pseudo_objects
         
-        start = 0
-        if marker:
-            try:
-                start = [x[0] for x in objects].index(marker) + 1
-            except ValueError:
-                pass
-        if not limit or limit > 10000:
-            limit = 10000
+        start, limit = self._list_limits([x[0] for x in objects], marker, limit)
         return objects[start:start + limit]
     
     def _del_version(self, version):