示例#1
0
 def __init__(self, data_manager, cache_manager, task_manager, settings, logger):
     self._db = data_manager
     self._cache = cache_manager
     self._tasks = task_manager
     self._settings = settings
     self._logger = logger
     # Our current folder permissions data version number.
     # If the database has a newer version we need to re-read it.
     # If a cached item has a lower version we need to discard it.
     self._fp_data_version = -1
     self._fp_last_check = None
     self._fp_refresh_lock = threading.Lock()
     # Keep a cache of the public (unknown user) folder permissions
     self._public_fp_cache = KeyValueCache()
示例#2
0
class PermissionsManager(object):
    """
    Provides permissions checking routines for the image server,
    backed by various caches for performance.

    Implementation notes:

    System permissions are calculated by combining a user's group permissions.
    Only logged-in users have system permissions.

    Folder permissions are more difficult to calculate (because they are more
    granular, span across groups, and employ inheritance) and accessing them
    requires the app's singleton instance of PermissionsManager.

    Folder permissions for unknown users are cached in our own in-memory cache.
    Because this looks only at the Public group, it has a finite (and relatively
    small) size, and entries can be stored "permanently" (until the permissions
    definitions change in the database).

    Folder permissions for logged-in users must be calculated on a per user +
    folder basis, giving potentially a very large number of combinations. These
    are cached in Memcached, entries therefore being shared among all image
    server processes, with a timeout, and subject to Memcached's LRU eviction
    policy.

    Changes made to the logic of:
        is_folder_permitted() or
        calculate_folder_permissions()
    need to be mirrored in trace_folder_permissions().
    """
    FP_CACHE_SYNC_INTERVAL = 60
    USER_PERMISSIONS_TIMEOUT = 3600

    def __init__(self, data_manager, cache_manager, task_manager, settings, logger):
        self._db = data_manager
        self._cache = cache_manager
        self._tasks = task_manager
        self._settings = settings
        self._logger = logger
        # Our current folder permissions data version number.
        # If the database has a newer version we need to re-read it.
        # If a cached item has a lower version we need to discard it.
        self._fp_data_version = -1
        self._fp_last_check = None
        self._fp_refresh_lock = threading.Lock()
        # Keep a cache of the public (unknown user) folder permissions
        self._public_fp_cache = KeyValueCache()

    def is_permitted(self, flag, user=None):
        """
        Returns whether a user has a particular system permission.

        The flag parameter should be the name of a system permission or a
        SystemPermissions.PERMIT constant e.g. 'admin_files'. The following special
        flag names are also supported:

        'admin_any' - any admin flag

        This method checks for superuser access automatically.
        If user is None, the returned value is always False.
        """
        if user is not None:
            sys_perms = self.calculate_system_permissions(user)
            if sys_perms is not None:
                if sys_perms.admin_all:
                    # Superuser
                    return True
                elif flag == 'admin_any':
                    # Special flag
                    return sys_perms.admin_users or sys_perms.admin_files or \
                           sys_perms.admin_folios or sys_perms.admin_permissions
                else:
                    # Check just the specified flag name
                    return getattr(sys_perms, flag)
        return False

    def ensure_permitted(self, flag, user=None):
        """
        Calls is_permitted(), raising a SecurityError if the
        requested flag is not permitted, otherwise performing no action.
        """
        if not self.is_permitted(flag, user):
            raise SecurityError(
                SYS_PERMISSIONS_TEXT.get(flag, '<Unknown>') + ' permission is required'
            )

    def calculate_system_permissions(self, user):
        """
        Returns a SystemPermissions object containing a user's final/combined
        system permissions, based on the user's group membership.
        If user is None, the returned permissions are always False.
        """
        final_perms = SystemPermissions(None, False, False, False, False, False, False, False)
        if user is not None:
            db_user = user if self._db.attr_is_loaded(user, 'groups') else \
                              self._db.get_user(user.id, load_groups=True)
            for group in db_user.groups:
                group_perms = group.permissions
                final_perms.folios = final_perms.folios or group_perms.folios
                final_perms.reports = final_perms.reports or group_perms.reports
                final_perms.admin_users = final_perms.admin_users or group_perms.admin_users
                final_perms.admin_files = final_perms.admin_files or group_perms.admin_files
                final_perms.admin_folios = final_perms.admin_folios or group_perms.admin_folios
                final_perms.admin_permissions = final_perms.admin_permissions or group_perms.admin_permissions
                final_perms.admin_all = final_perms.admin_all or group_perms.admin_all
        return final_perms

    def is_folder_permitted(self, folder, folder_access, user=None, folder_must_exist=True):
        """
        Returns whether a user has the requested access level for the given
        folder, or alternatively has the file administration system permission.

        The folder parameter can be either a Folder object or a folder path.
        The folder_access parameter should be a FolderPermission.ACCESS constant.
        If user is None, the anonymous user's permissions are checked.
        If folder_must_exist is False, the folder parameter can be a path that
        does not yet exist (access will be checked for the nearest existing path).

        This method checks for superuser access automatically.

        Raises a ValueError if folder is None.
        Raises a DoesNotExistError if folder_must_exist is True but the folder
        is an invalid path, or if a user is provided but is not a valid user.
        """
        #     Check system permissions first as this is faster and easier
        # !!! Also update _trace_folder_permissions() if changing this !!!
        if self.is_permitted(SystemPermissions.PERMIT_ADMIN_FILES, user):
            return True
        # Check folder permissions
        level = self.calculate_folder_permissions(folder, user, folder_must_exist)
        return level >= folder_access

    def ensure_folder_permitted(self, folder, folder_access, user=None, folder_must_exist=True):
        """
        Calls is_folder_permitted(), additionally raising a SecurityError if
        the requested flag is not permitted, otherwise performing no action.
        """
        if not self.is_folder_permitted(folder, folder_access, user, folder_must_exist):
            folder_path = folder.path if hasattr(folder, 'path') else folder
            raise SecurityError(
                FOLDER_ACCESS_TEXT.get(folder_access, '<Unknown>') +
                ' permission is required for ' + folder_path
            )

    def calculate_folder_permissions(self, folder, user=None, folder_must_exist=True):
        """
        Returns an integer indicating the highest access level that is permitted
        for a folder, based on all a user's groups. This value will be returned
        from cache if possible.

        The folder parameter can be either a Folder object or a folder path.
        If user is None, the anonymous user's permissions are checked, using
        the Public group.
        If folder_must_exist is False, the folder parameter can be a path that
        does not yet exist (access will be calculated for the nearest existing
        path).

        Raises a ValueError if folder is None.
        Raises a DoesNotExistError if folder_must_exist is True but the folder
        is an invalid path, or if a user is provided but is not a valid user.
        """
        if folder is None:
            raise ValueError('Empty folder provided for permissions checking')

        folder_path = self._normalize_path(
            folder.path if hasattr(folder, 'path') else folder
        )

        # Periodically ensure our data is up to date
        self._check_data_version()
        # Note this now in case another thread changes it mid-flow further on
        current_version = self._fp_data_version

        # Try the cache first
        if user is None:
            cache_val = self._public_fp_cache.get(folder_path)
        else:
            cache_val = self._cache.raw_get(
                self._get_cache_key(user, folder_path),
                integrity_check=True
            )
        # Cache entries are (value, version)
        if cache_val and cache_val[1] == current_version:
            return cache_val[0]

        #     We need to (re)calculate the folder access
        # !!! Also update _trace_folder_permissions() if changing this !!!
        db_session = self._db.db_get_session()
        db_commit = False
        try:
            # Get the folder and public group objects
            db_folder = db_session.merge(folder, load=False) if hasattr(folder, 'path') else \
                        auto_sync_folder(folder, self._db, self._tasks, _db_session=db_session)
            db_public_group = self._db.get_group(
                group_id=Group.ID_PUBLIC,
                load_users=False,
                _db_session=db_session
            )
            # Handle non-existent folder
            if db_folder is None and not folder_must_exist:
                db_folder = _get_nearest_parent_folder(folder_path, self._db, db_session)
            # Hopefully won't need these
            if db_folder is None:
                raise DoesNotExistError(folder_path)
            if db_public_group is None:
                raise DoesNotExistError('Public group')

            # Get the Public group access
            public_permission = self._db.get_nearest_folder_permission(
                db_folder,
                db_public_group,
                _db_session=db_session
            )
            if public_permission is None:
                # Hopefully never get here
                self._logger.error('No root folder permission found for the Public group')
                public_permission = FolderPermission(
                    db_folder, db_public_group, FolderPermission.ACCESS_NONE
                )

            if user is None:
                # Debug log only
                if self._settings['DEBUG']:
                    self._logger.debug(
                        'Public access to folder ' + folder_path +
                        ' is ' + str(public_permission)
                    )
                # Add result to cache and return it
                self._public_fp_cache.set(
                    folder_path,
                    (public_permission.access, current_version)
                )
                db_commit = True
                return public_permission.access
            else:
                db_user = user if self._db.attr_is_loaded(user, 'groups') else \
                          self._db.get_user(user.id, load_groups=True, _db_session=db_session)
                # Hopefully won't need this
                if db_user is None:
                    raise DoesNotExistError('User %d' % user.id)

                # Look at access for each of the user's groups
                final_access = FolderPermission.ACCESS_NONE
                for db_user_group in db_user.groups:
                    g_permission = self._db.get_nearest_folder_permission(
                        db_folder,
                        db_user_group,
                        _db_session=db_session
                    )
                    # The final access = the highest level from all the groups
                    if g_permission is not None and g_permission.access > final_access:
                        final_access = g_permission.access
                        # Fast path
                        if final_access == FolderPermission.ACCESS_ALL:
                            break
                # Use the public group access as a fallback
                if public_permission.access > final_access:
                    final_access = public_permission.access
                # Debug log only
                if self._settings['DEBUG']:
                    self._logger.debug(
                        str(user) + '\'s access to folder ' + folder_path +
                        ' is ' + str(final_access)
                    )
                # Add result to cache and return it
                self._cache.raw_put(
                    self._get_cache_key(user, folder_path),
                    (final_access, current_version),
                    PermissionsManager.USER_PERMISSIONS_TIMEOUT,
                    integrity_check=True
                )
                db_commit = True
                return final_access

        finally:
            try:
                if db_commit:
                    db_session.commit()
                else:
                    db_session.rollback()
            finally:
                db_session.close()

    def reset(self):
        """
        Marks as expired all cached folder permissions data, for all image
        server processes, by incrementing the database data version number.
        """
        with self._fp_refresh_lock:
            new_ver = self._db.increment_property(Property.FOLDER_PERMISSION_VERSION)
        self._fp_last_check = datetime.min
        self._logger.info(
            'Folder permissions flagging new version ' + new_ver
        )

    def _trace_folder_permissions(self, folder, user=None, check_consistency=True):
        """
        Calculates the separate set of system and folder permissions that go to
        make up a given user's final permission for a particular folder. This
        function is for administration purposes only - it is designed to return
        verbose information and is not optimised for efficiency. It does not
        employ any caching.

        Internally, this function duplicates the logic of the "public" interfaces.
        This is not ideal, but with caching and fast paths removed, the flow is
        sufficiently different as to make code re-use impractical.
        The check_consistency parameter (default True) forces a compare of the
        output of this function with the others, and raises a ValueError if a
        result mismatch is detected. This would indicate a bug.

        If the supplied user is None, the permissions returned are for an
        anonymous user and the Public group.

        The calculated items are returned as a dictionary with the following
        entries:

        {
            'user':   User object requested (or None),
            'folder': Folder object requested,
            'groups': [
                { 'group': Public Group object including (empty) system permissions,
                  'folder_permission': FolderPermission object for Public group +
                                       nearest folder to folder requested
                },
                { 'group': Group object including system permissions,
                  'folder_permission': FolderPermission object for group +
                                       nearest folder to the folder requested
                                       (can be None)
                },
                # The user's next group
            ],
            'access': Overall access level as a FolderPermission.ACCESS constant
        }

        Raises a ValueError if folder is None or if the consistency check fails.
        Raises a DoesNotExistError if the folder or user provided is invalid.
        """
        # Work from the latest data
        # (and if checking consistency later, ensure the cache is up to date)
        self._check_data_version(_force=True)

        db_session = self._db.db_get_session()
        db_commit = False
        try:
            # Get the folder, user and public group database objects
            db_folder = self._db.get_folder(folder.id, _db_session=db_session)
            db_user = None if user is None else self._db.get_user(user.id, load_groups=True, _db_session=db_session)
            db_public_group = self._db.get_group(group_id=Group.ID_PUBLIC, load_users=False, _db_session=db_session)
            # Hopefully won't need these
            if db_folder is None:
                raise DoesNotExistError(folder.path)
            if db_public_group is None:
                raise DoesNotExistError('Public group')

            # Start the trace
            trace = {
                'user': db_user,
                'folder': db_folder,
                'groups': [],
                'access': FolderPermission.ACCESS_NONE
            }

            # Add the Public group permissions
            public_fp = self._db.get_nearest_folder_permission(
                db_folder, db_public_group,
                _load_nearest_folder=True,
                _db_session=db_session
            )
            trace['groups'].append({
                'group': db_public_group,
                'folder_permission': public_fp
            })
            if public_fp is not None:
                trace['access'] = public_fp.access

            # Add the user's group permissions
            if db_user is not None:
                for db_group in db_user.groups:
                    group_fp = self._db.get_nearest_folder_permission(
                        db_folder, db_group,
                        _load_nearest_folder=True,
                        _db_session=db_session
                    )
                    trace['groups'].append({
                        'group': db_group,
                        'folder_permission': group_fp
                    })
                    # Update overall access
                    if db_group.permissions.admin_files or db_group.permissions.admin_all:
                        trace['access'] = FolderPermission.ACCESS_ALL
                    elif group_fp is not None:
                        if group_fp.access > trace['access']:
                            trace['access'] = group_fp.access

            # Verify the result against ourself
            if check_consistency:
                sp_level = FolderPermission.ACCESS_ALL if \
                           self.is_permitted('admin_files', db_user) else \
                           FolderPermission.ACCESS_NONE
                fp_level = self.calculate_folder_permissions(db_folder, db_user)
                check_access = max(sp_level, fp_level)
                if check_access != trace['access']:
                    raise ValueError('Data integrity error. Permissions manager has access level %d but traced access level %d for folder ID %d and user ID %d.' % (
                        check_access, trace['access'], db_folder.id,
                        0 if db_user is None else db_user.id
                    ))

            db_commit = True
            return trace
        finally:
            try:
                if db_commit:
                    db_session.commit()
                else:
                    db_session.rollback()
            finally:
                db_session.close()

    def _check_data_version(self, _force=False):
        """
        Periodically checks for changes in the permissions data, sets the
        internal data version number, and resets caches if necessary.
        Uses an internal lock for thread safety.
        """
        if (self._fp_data_version < 0) or (self._fp_last_check is None):
            # Start up
            db_ver = self._db.get_object(Property, Property.FOLDER_PERMISSION_VERSION)
            self._fp_data_version = int(db_ver.value)
            self._fp_last_check = datetime.utcnow()
            self._logger.info(
                'Folder permissions initialising with version ' + str(self._fp_data_version)
            )
        elif _force or (self._fp_last_check < (
            datetime.utcnow() - timedelta(seconds=PermissionsManager.FP_CACHE_SYNC_INTERVAL)
        )):
            # Check for newer data version
            if self._fp_refresh_lock.acquire(0):      # 0 = nonblocking
                try:
                    old_ver = self._fp_data_version
                    db_ver = self._db.get_object(Property, Property.FOLDER_PERMISSION_VERSION)
                    if int(db_ver.value) != old_ver:
                        self._fp_data_version = int(db_ver.value)
                        self._public_fp_cache.clear()
                        self._logger.info(
                            'Folder permissions detected new version ' +
                            str(self._fp_data_version)
                        )
                    self._fp_last_check = datetime.utcnow()
                finally:
                    self._fp_refresh_lock.release()

    def _get_cache_key(self, user, path):
        """
        Returns the cache key to use for caching a user+folder permission.
        This takes the folder path so that cache lookups can be performed
        (from the plain image API) without requiring database access.
        """
        phash = path if len(path) < 200 else (
            str(hash(path)) + '_' + str(zlib.crc32(path))
        )
        return "FPERM:" + str(user.id) + ":" + phash

    def _normalize_path(self, path):
        """
        Converts a path to a standard format (removes leading and trailing
        slashes, apart from the root folder).
        """
        np = strip_seps(filepath_normalize(path))
        return np if np else os.path.sep