changeset 451:6cd51ea6dfcf

auth: Rewrite permission system and improve support for it. - More proper ACL model for permissions. - Page-level ACL is only specified locally, not inherited anymore. - Protect more API and UI routes with permission checks. - Improve error handling and error pages.
author Ludovic Chabant <ludovic@chabant.com>
date Sun, 07 Jan 2018 11:11:04 -0800
parents ab47d3cf5e1e
children 4ef0a26d0153
files tests/test_auth.py wikked/api/admin.py wikked/api/edit.py wikked/api/history.py wikked/api/read.py wikked/api/special.py wikked/api/user.py wikked/auth.py wikked/commonroutes.py wikked/page.py wikked/resolver.py wikked/templates/error-unauthorized.html wikked/templates/error.html wikked/templates/page_error.html wikked/utils.py wikked/views/__init__.py wikked/views/admin.py wikked/views/edit.py wikked/views/error.py wikked/views/history.py wikked/views/read.py wikked/views/special.py wikked/views/user.py wikked/web.py wikked/webimpl/__init__.py wikked/webimpl/decorators.py wikked/webimpl/edit.py wikked/webimpl/history.py wikked/webimpl/read.py wikked/webimpl/special.py
diffstat 30 files changed, 781 insertions(+), 426 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_auth.py	Sun Jan 07 11:11:04 2018 -0800
@@ -0,0 +1,98 @@
+import pytest
+from configparser import SafeConfigParser
+from wikked.auth import (
+        UserManager, PERM_NAMES,
+        NoSuchGroupOrUserError, MultipleGroupMembershipError,
+        CyclicUserGroupError, InvalidPermissionError)
+
+
+def _user_manager_from_str(txt):
+    config = SafeConfigParser()
+    config.read_string(txt)
+    return UserManager(config)
+
+
+def _p(name):
+    return PERM_NAMES[name]
+
+
+def test_empty_auth():
+    m = _user_manager_from_str("")
+    assert list(m.getUserNames()) == ['anonymous']
+    assert list(m.getGroupNames()) == ['*']
+
+
+def test_missing_user1():
+    with pytest.raises(NoSuchGroupOrUserError):
+        m = _user_manager_from_str("""
+[permissions]
+dorothy = read
+""")
+
+
+def test_missing_user2():
+    with pytest.raises(NoSuchGroupOrUserError):
+        m = _user_manager_from_str("""
+[groups]
+mygroup = dorothy
+""")
+
+
+def test_multiple_group_membership1():
+    with pytest.raises(MultipleGroupMembershipError):
+        m = _user_manager_from_str("""
+[users]
+dorothy = pass
+[groups]
+one = dorothy
+two = dorothy
+""")
+
+
+def test_multiple_group_membership2():
+    with pytest.raises(MultipleGroupMembershipError):
+        m = _user_manager_from_str("""
+[users]
+dorothy = pass
+[groups]
+one = dorothy
+two = one
+three = one
+""")
+
+
+def test_auth1():
+    m = _user_manager_from_str("""
+[users]
+dorothy = pass
+[permissions]
+dorothy = read,edit
+""")
+    assert m.hasPermission('dorothy', _p('read'))
+    assert m.hasPermission('dorothy', _p('edit'))
+    assert not m.hasPermission('dorothy', _p('create'))
+
+
+def test_auth2():
+    m = _user_manager_from_str("""
+[users]
+dorothy = pass
+toto = pass
+tinman = pass
+[groups]
+humans = dorothy
+others = toto, tinman
+[permissions]
+humans = read,edit
+others = read
+tinman = create
+""")
+    assert m.hasPermission('dorothy', _p('read'))
+    assert m.hasPermission('dorothy', _p('edit'))
+    assert not m.hasPermission('dorothy', _p('create'))
+    assert m.hasPermission('toto', _p('read'))
+    assert not m.hasPermission('toto', _p('edit'))
+    assert not m.hasPermission('toto', _p('create'))
+    assert m.hasPermission('tinman', _p('read'))
+    assert not m.hasPermission('tinman', _p('edit'))
+    assert m.hasPermission('tinman', _p('create'))
--- a/wikked/api/admin.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/api/admin.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,69 +1,12 @@
-from flask import jsonify, abort, request
-from flask.ext.login import logout_user, current_user
-from wikked.web import app, get_wiki, login_manager
-from wikked.webimpl.admin import do_login_user
+from flask import jsonify
+from wikked.web import app, get_wiki
+from wikked.webimpl.decorators import requires_permission
 
 
 @app.route('/api/admin/reindex', methods=['POST'])
+@requires_permission('index')
 def api_admin_reindex():
-    if not current_user.is_authenticated() or not current_user.is_admin():
-        return login_manager.unauthorized()
     wiki = get_wiki()
     wiki.index.reset(wiki.getPages())
     result = {'ok': 1}
     return jsonify(result)
-
-
-@app.route('/api/user/login', methods=['POST'])
-def api_user_login():
-    if do_login_user():
-        username = request.form.get('username')
-        result = {'username': username, 'logged_in': 1}
-        return jsonify(result)
-    abort(401)
-
-
-@app.route('/api/user/is_logged_in')
-def api_user_is_logged_in():
-    if current_user.is_authenticated():
-        result = {'logged_in': True}
-        return jsonify(result)
-    abort(401)
-
-
-@app.route('/api/user/logout', methods=['POST'])
-def api_user_logout():
-    logout_user()
-    result = {'ok': 1}
-    return jsonify(result)
-
-
-@app.route('/api/user/info')
-def api_current_user_info():
-    user = current_user
-    if user.is_authenticated():
-        result = {
-                'user': {
-                    'username': user.username,
-                    'groups': user.groups
-                    }
-                }
-        return jsonify(result)
-    return jsonify({'user': False})
-
-
-@app.route('/api/user/info/<name>')
-def api_user_info(name):
-    wiki = get_wiki()
-    user = wiki.auth.getUser(name)
-    if user is not None:
-        result = {
-                'user': {
-                    'username': user.username,
-                    'groups': user.groups
-                    }
-                }
-        return jsonify(result)
-    abort(404)
-
-
--- a/wikked/api/edit.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/api/edit.py	Sun Jan 07 11:11:04 2018 -0800
@@ -2,11 +2,13 @@
 from flask.ext.login import current_user
 from wikked.web import app, get_wiki
 from wikked.webimpl import url_from_viewarg, split_url_from_viewarg
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.edit import (
         get_edit_page, do_edit_page, preview_edited_page)
 
 
 @app.route('/api/edit/<path:url>', methods=['GET', 'POST'])
+@requires_permission('edit')
 def api_edit_page(url):
     wiki = get_wiki()
     user = current_user.get_id()
@@ -45,6 +47,7 @@
 
 
 @app.route('/api/preview', methods=['POST'])
+@requires_permission('edit')
 def api_preview():
     url = request.form.get('url')
     if url == '' or not url[0] == '/':
@@ -58,16 +61,19 @@
 
 
 @app.route('/api/rename/<path:url>', methods=['POST'])
+@requires_permission(['create', 'delete'])
 def api_rename_page(url):
-    pass
+    raise NotImplementedError()
 
 
 @app.route('/api/delete/<path:url>', methods=['POST'])
+@requires_permission('delete')
 def api_delete_page(url):
-    pass
+    raise NotImplementedError()
 
 
 @app.route('/api/validate/newpage', methods=['GET', 'POST'])
+@requires_permission('create')
 def api_validate_newpage():
     path = request.form.get('title')
     if path is None:
@@ -84,4 +90,3 @@
     except Exception:
         return '"This page name is invalid or unavailable"'
     return '"true"'
-
--- a/wikked/api/history.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/api/history.py	Sun Jan 07 11:11:04 2018 -0800
@@ -2,12 +2,14 @@
 from flask.ext.login import current_user
 from wikked.web import app, get_wiki
 from wikked.webimpl import url_from_viewarg
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.history import (
         get_site_history, get_page_history,
         read_page_rev, diff_page_revs)
 
 
 @app.route('/api/site-history')
+@requires_permission('wikihistory')
 def api_site_history():
     wiki = get_wiki()
     user = current_user.get_id()
@@ -23,6 +25,7 @@
 
 
 @app.route('/api/history/<path:url>')
+@requires_permission('history')
 def api_page_history(url):
     wiki = get_wiki()
     user = current_user.get_id()
@@ -32,6 +35,7 @@
 
 
 @app.route('/api/revision/<path:url>')
+@requires_permission('history')
 def api_read_page_rev(url):
     wiki = get_wiki()
     user = current_user.get_id()
@@ -44,6 +48,7 @@
 
 
 @app.route('/api/diff/<path:url>')
+@requires_permission('history')
 def api_diff_page(url):
     wiki = get_wiki()
     user = current_user.get_id()
@@ -59,6 +64,7 @@
 
 
 @app.route('/api/revert/<path:url>', methods=['POST'])
+@requires_permission('revert')
 def api_revert_page(url):
     # TODO: only users with write access can revert.
     if 'rev' not in request.form:
@@ -83,4 +89,3 @@
     wiki.revertPage(url, page_fields)
     result = {'reverted': 1}
     return jsonify(result)
-
--- a/wikked/api/read.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/api/read.py	Sun Jan 07 11:11:04 2018 -0800
@@ -4,22 +4,24 @@
 from wikked.utils import PageNotFoundError
 from wikked.web import app, get_wiki
 from wikked.webimpl import (
-        CHECK_FOR_READ,
-        url_from_viewarg,
-        get_page_or_raise,
-        get_page_meta,
-        PageNotFoundError, RedirectNotFoundError, CircularRedirectError)
+    url_from_viewarg,
+    get_page_or_raise,
+    get_page_meta,
+    RedirectNotFoundError, CircularRedirectError)
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.read import (
-        read_page, get_incoming_links, get_outgoing_links)
+    read_page, get_incoming_links, get_outgoing_links)
 from wikked.webimpl.special import list_pages
 
 
 @app.route('/api/list')
+@requires_permission('list')
 def api_list_all_pages():
     return api_list_pages(None)
 
 
 @app.route('/api/list/<path:url>')
+@requires_permission('list')
 def api_list_pages(url):
     wiki = get_wiki()
     url = url_from_viewarg(url)
@@ -61,9 +63,9 @@
     url = url_from_viewarg(url)
     try:
         page = get_page_or_raise(
-                wiki, url,
-                check_perms=(user, CHECK_FOR_READ),
-                fields=['raw_text', 'meta'])
+            wiki, url,
+            check_perms=(user, 'read'),
+            fields=['raw_text', 'meta'])
     except PageNotFoundError as e:
         app.logger.exception(e)
         abort(404)
@@ -73,14 +75,16 @@
 
 
 @app.route('/api/query')
+@requires_permission('search')
 def api_query():
     wiki = get_wiki()
     query = dict(request.args)
     pages = wiki.getPages(meta_query=query)
     result = {
-            'query': query,
-            'pages': [get_page_meta(p) for p in pages]
-            }
+        'query': query,
+        # TODO: filter pages we don't have permission to.
+        'pages': [get_page_meta(p) for p in pages]
+    }
     return jsonify(result)
 
 
@@ -96,7 +100,7 @@
     user = current_user.get_id()
     page = get_page_or_raise(
             wiki, url,
-            check_perms=(user, CHECK_FOR_READ),
+            check_perms=(user, 'read'),
             fields=['url', 'title', 'path', 'meta'])
     state = page.getState()
     return jsonify({
--- a/wikked/api/special.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/api/special.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,6 +1,7 @@
 from flask import jsonify, request, abort
 from flask.ext.login import current_user
 from wikked.web import app, get_wiki
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.special import (
         get_orphans, get_broken_redirects, get_double_redirects,
         get_dead_ends, get_search_results, get_search_preview_results)
@@ -34,6 +35,7 @@
 
 
 @app.route('/api/search')
+@requires_permission('search')
 def api_search():
     query = request.args.get('q')
     if query is None or query == '':
@@ -42,10 +44,9 @@
 
 
 @app.route('/api/searchpreview')
+@requires_permission('search')
 def api_search_preview():
     query = request.args.get('q')
     if query is None or query == '':
         abort(400)
     return call_api(get_search_preview_results, query=query)
-
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/api/user.py	Sun Jan 07 11:11:04 2018 -0800
@@ -0,0 +1,57 @@
+from flask import jsonify, abort, request
+from flask.ext.login import logout_user, current_user
+from wikked.web import app, get_wiki
+from wikked.webimpl.admin import do_login_user
+
+
+@app.route('/api/user/login', methods=['POST'])
+def api_user_login():
+    if do_login_user():
+        username = request.form.get('username')
+        result = {'username': username, 'logged_in': 1}
+        return jsonify(result)
+    abort(401)
+
+
+@app.route('/api/user/is_logged_in')
+def api_user_is_logged_in():
+    if current_user.is_authenticated():
+        result = {'logged_in': True}
+        return jsonify(result)
+    abort(401)
+
+
+@app.route('/api/user/logout', methods=['POST'])
+def api_user_logout():
+    logout_user()
+    result = {'ok': 1}
+    return jsonify(result)
+
+
+@app.route('/api/user/info')
+def api_current_user_info():
+    user = current_user
+    if user.is_authenticated():
+        result = {
+            'user': {
+                'username': user.username,
+                'groups': user.groups
+            }
+        }
+        return jsonify(result)
+    return jsonify({'user': False})
+
+
+@app.route('/api/user/info/<name>')
+def api_user_info(name):
+    wiki = get_wiki()
+    user = wiki.auth.getUser(name)
+    if user is not None:
+        result = {
+            'user': {
+                'username': user.username,
+                'groups': user.groups
+            }
+        }
+        return jsonify(result)
+    abort(404)
--- a/wikked/auth.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/auth.py	Sun Jan 07 11:11:04 2018 -0800
@@ -5,12 +5,62 @@
 logger = logging.getLogger(__name__)
 
 
-class User(object):
+# Page permissions.
+PERM_NONE = 0
+PERM_READ = 2**0
+PERM_EDIT = 2**1
+PERM_CREATE = 2**2
+PERM_DELETE = 2**3
+PERM_HISTORY = 2**4
+PERM_REVERT = 2**5
+PERM_SEARCH = 2**6
+# Site-wide premissions.
+PERM_INDEX = 2**7
+PERM_LIST = 2**8
+PERM_LISTREFRESH = 2**9
+PERM_WIKIHISTORY = 2**10
+PERM_USERS = 2**11
+
+PERM_NAMES = {
+        # Page permissions.
+        'none': PERM_NONE,
+        'read': PERM_READ,
+        'edit': PERM_EDIT,
+        'create': PERM_CREATE,
+        'delete': PERM_DELETE,
+        'history': PERM_HISTORY,
+        'revert': PERM_REVERT,
+        'search': PERM_SEARCH,
+        # Site-wide permissions.
+        'index': PERM_INDEX,
+        'list': PERM_LIST,
+        'listrefresh': PERM_LISTREFRESH,
+        'wikihistory': PERM_WIKIHISTORY,
+        'users': PERM_USERS,
+        # Aliases
+        'write': PERM_EDIT,
+        'all': 0xffff
+        }
+
+ANONYMOUS_USERNAME = 'anonymous'
+ALL_USERS_GROUP = '*'
+
+DEFAULT_USER_ROLES = {
+        'reader': (PERM_READ | PERM_HISTORY),
+        'contributor': (PERM_READ | PERM_EDIT | PERM_HISTORY),
+        'editor': (PERM_READ | PERM_EDIT | PERM_CREATE | PERM_DELETE |
+                   PERM_HISTORY | PERM_REVERT),
+        'admin': 0xffff
+        }
+
+
+class User:
     """ A user with an account on the wiki.
     """
     def __init__(self, username, password):
         self.username = username
         self.password = password
+        self.permissions = PERM_NONE
         self.groups = []
 
     def is_authenticated(self):
@@ -25,16 +75,225 @@
     def get_id(self):
         return str(self.username)
 
-    def is_admin(self):
-        return 'administrators' in self.groups
+
+class NoSuchGroupOrUserError(Exception):
+    pass
+
+
+class MultipleGroupMembershipError(Exception):
+    pass
+
+
+class CyclicUserGroupError(Exception):
+    pass
+
+
+class InvalidPermissionError(Exception):
+    pass
+
+
+class _UserInfo:
+    def __init__(self, password):
+        self.password = password
+        self.allows = PERM_NONE
+        self.denies = PERM_NONE
+        self.group_name = None
+        self.flattened_perms = PERM_NONE
+        self.flattened_lineage = None
+
+
+class _UserGroupInfo:
+    def __init__(self):
+        self.allows = PERM_NONE
+        self.denies = PERM_NONE
+        self.parent_group_name = None
+
+
+class _UserRoleInfo:
+    def __init__(self):
+        self.allows = PERM_NONE
+        self.denies = PERM_NONE
+
+
+re_sep = re.compile(r'[,;]')
+
+
+def _parse_permission(perm):
+    # 'perm' or '+perm' means 'allows'. '-perm' means 'denies'.
+    is_allow = True
+    if perm[0] == '-':
+        perm = perm[1:]
+        is_allow = False
+
+    p_bit = PERM_NAMES.get(perm)
+    if p_bit is not None:
+        return p_bit, is_allow
+    raise InvalidPermissionError(perm)
+
+
+def _parse_permission_list(permlist):
+    perms = [p.strip() for p in re_sep.split(permlist)]
+    allows = PERM_NONE
+    denies = PERM_NONE
+    for p in perms:
+        p_bit, is_allow = _parse_permission(p)
+        if is_allow:
+            allows |= p_bit
+        else:
+            denies |= p_bit
+    return allows, denies
 
 
-class UserManager(object):
+def parse_config(config, roles, groups, users):
+    member_map = {}
+
+    # Pre-populate the default roles, and then read the user-defined
+    # list of roles.
+    for name, bits in DEFAULT_USER_ROLES.items():
+        ri = _UserRoleInfo()
+        ri.allows = bits
+        roles[name] = ri
+    if config.has_section('roles'):
+        for role in config.items('roles'):
+            ri = _UserRoleInfo()
+            ri.allows, ri.denies = _parse_permission_list(role[1])
+            roles[role[0]] = ri
+
+    # Get the list of groups.
+    if config.has_section('groups'):
+        for group in config.items('groups'):
+            # Just create the group for now, and store members in a temp
+            # map, because some members might only be declared later.
+            groups[group[0]] = _UserGroupInfo()
+            member_map[group[0]] = [m.strip() for m in re_sep.split(group[1])]
+
+    # Get the list of users and passwords.
+    if config.has_section('users'):
+        for user in config.items('users'):
+            users[user[0]] = _UserInfo(user[1])
+
+    # Now resolve group membership -- we should have all the users
+    # and groups known at this point.
+    for name, members in member_map.items():
+        for m in members:
+            # Is it a user?
+            u = users.get(m)
+            if u is not None:
+                if u.group_name is not None:
+                    raise MultipleGroupMembershipError(
+                            "User '%s' can't be added to group '%s' "
+                            "because it already belongs to group '%s'." %
+                            (m, name, u.group_name))
+                u.group_name = name
+                continue
+
+            # Is it a group then?
+            g = groups.get(m)
+            if g is not None:
+                if g.parent_group_name is not None:
+                    raise MultipleGroupMembershipError(
+                            "Group '%s' can't be added to group '%s' "
+                            "because it already belongs to group '%s'." %
+                            (m, name, g.parent_group_name))
+                g.parent_group_name = name
+                continue
+
+            # Can't find it!
+            raise NoSuchGroupOrUserError(m)
+
+    # Add entries for "all known users" and "anonymous users".
+    # Those are potentially referenced in the 'permissions' section to
+    # assign broad access levels.
+    users[ANONYMOUS_USERNAME] = _UserInfo(None)
+    groups[ALL_USERS_GROUP] = _UserGroupInfo()
+
+    # Assign permissions.
+    if config.has_section('permissions'):
+        for perm in config.items('permissions'):
+            # Get the user or group subject.
+            subj = users.get(perm[0])
+            if subj is None:
+                subj = groups.get(perm[0])
+                if subj is None:
+                    raise NoSuchGroupOrUserError(perm[0])
+
+            # Get the permission/role list.
+            allows = PERM_NONE
+            denies = PERM_NONE
+            perms = [p.strip() for p in re_sep.split(perm[1])]
+            for p in perms:
+                # If it's a role, just combine its allow/deny lists.
+                role = roles.get(p)
+                if role is not None:
+                    allows |= role.allows
+                    denies |= role.denies
+                    continue
+
+                # Otherwise, parse actual permissions as usual.
+                p_bit, is_allow = _parse_permission(p)
+                if is_allow:
+                    allows |= p_bit
+                else:
+                    denies |= p_bit
+            subj.allows |= allows
+            subj.denies |= denies
+    else:
+        # No permissions specified... use the defaults.
+        users[ANONYMOUS_USERNAME].allows = DEFAULT_USER_ROLES['admin']
+        groups[ALL_USERS_GROUP].allows = DEFAULT_USER_ROLES['admin']
+
+    # Flatten user permissions so we don't have to go through the tree
+    # all the time, and so we can detect cyclic problems right away.
+    for username, user_info in users.items():
+        group_lineage = _get_group_lineage(user_info, groups)
+        if username != ANONYMOUS_USERNAME:
+            group_lineage.append(ALL_USERS_GROUP)
+        # Walk the lineage the other way, i.e. from the root group down
+        # to the user itself.
+        user_info.flattened_lineage = list(reversed(group_lineage))
+        for gn in user_info.flattened_lineage:
+            ginfo = groups[gn]
+            user_info.flattened_perms |= ginfo.allows
+            user_info.flattened_perms &= ~ginfo.denies
+        user_info.flattened_perms |= user_info.allows
+        user_info.flattened_perms &= ~user_info.denies
+
+
+def _get_group_lineage(user_info, groups):
+    lineage = []
+    if user_info.group_name is not None:
+        _do_get_group_lineage(groups, user_info.group_name, lineage)
+    return lineage
+
+
+def _do_get_group_lineage(groups, group_name, lineage):
+    # Check cycles.
+    if group_name in lineage:
+        raise CyclicUserGroupError("Group '%s' is in a parenting cycle: %s" %
+                                   (group_name, ' -> '.join(lineage)))
+    # Check existence.
+    group_info = groups.get(group_name)
+    if group_info is None:
+        raise NoSuchGroupOrUserError(group_name)
+
+    # Yep, it's all good. Add the group to the lineage, and keep walking
+    # up the parent chain.
+    lineage.append(group_name)
+    if group_info.parent_group_name:
+        _do_get_group_lineage(groups, group_info.parent_group_name, lineage)
+
+
+re_page_acl = re.compile(r'^(?P<name>[^\s]+)\s*\=\s*(?P<perms>.*)$')
+
+
+class UserManager:
     """ A class that keeps track of users and their permissions.
     """
     def __init__(self, config):
-        self._updatePermissions(config)
-        self._updateUserInfos(config)
+        self._roles = {}
+        self._groups = {}
+        self._users = {}
+        parse_config(config, self._roles, self._groups, self._users)
 
     def start(self, wiki):
         pass
@@ -46,89 +305,78 @@
         pass
 
     def getUsers(self):
-        for user in self._users:
-            yield self._createUser(user)
+        for name, info in self._users.items():
+            yield self._createUser(name, info)
+
+    def getUserNames(self):
+        return self._users.keys()
 
     def getUser(self, username):
-        for user in self._users:
-            if user['username'] == username:
-                return self._createUser(user)
+        info = self._users.get(username)
+        if info is not None:
+            return self._createUser(username, info)
         return None
 
-    def isPageReadable(self, page, username):
-        return self._isAllowedForMeta(page, 'readers', username)
-
-    def isPageWritable(self, page, username):
-        return self._isAllowedForMeta(page, 'writers', username)
-
-    def hasPermission(self, meta_name, username):
-        perm = self._permissions.get(meta_name)
-        if perm is not None:
-            # Permissions are declared at the wiki level.
-            if username is None and 'anonymous' in perm:
-                return True
-            if username is not None and (
-                    '*' in perm or username in perm):
-                return True
-            return False
+    def getGroupNames(self):
+        return self._groups.keys()
 
-        return True
-
-    def _isAllowedForMeta(self, page, meta_name, username):
-        perm = page.getMeta(meta_name)
-        if perm is not None:
-            # Permissions are declared at the page level.
-            if isinstance(perm, list):
-                perm = ','.join(perm)
+    def hasPagePermission(self, page, username, perm):
+        extra_acl = None
+        page_perms = page.getLocalMeta('acl')
+        if page_perms is not None:
+            extra_acl = []
+            for pp in page_perms:
+                m = re_page_acl.match(pp)
+                if m:
+                    name = m.group('name')
+                    perms = [p.strip()
+                             for p in re_sep.split(m.group('perms'))]
+                    extra_acl.append((name, perms))
 
-            allowed = [r.strip() for r in re.split(r'[ ,;]', perm)]
-            if username is None and 'anonymous' in allowed:
-                return True
-            if username is not None and (
-                    '*' in allowed or username in allowed):
-                return True
+        return self.hasPermission(username, perm, extra_acl)
 
-            return False
-
-        return self.hasPermission(meta_name, username)
+    def hasPermission(self, username, perm, extra_acl=None):
+        username = username or ANONYMOUS_USERNAME
+        user_info = self._users.get(username)
+        if user_info is None:
+            raise NoSuchGroupOrUserError(username)
 
-    def _updatePermissions(self, config):
-        self._permissions = {
-                'readers': None,
-                'writers': None
-                }
-        if config.has_option('permissions', 'readers'):
-            self._permissions['readers'] = [
-                p.strip()
-                for p in re.split(r'[ ,;]',
-                                  config.get('permissions', 'readers'))]
-        if config.has_option('permissions', 'writers'):
-            self._permissions['writers'] = [
-                p.strip()
-                for p in re.split(
-                        r'[ ,;]', config.get('permissions', 'writers'))]
+        # Start with the user permissions, and patch them with whatever
+        # extra permissions specify.
+        effective_perms = user_info.flattened_perms
+        if extra_acl is not None:
+            for name, perms in extra_acl:
+                if (name == username or
+                        name in user_info.flattened_lineage):
+                    for p in perms:
+                        if p[0] == '+':
+                            # Add permission.
+                            p_bit = self._getPermissions(p[1:])
+                            effective_perms |= p_bit
+                        elif p[0] == '-':
+                            # Remove permission.
+                            p_bit = self._getPermissions(p[1:])
+                            effective_perms &= ~p_bit
+                        else:
+                            # Replace permissions.
+                            p_bit = self._getPermissions(p)
+                            effective_perms = p_bit
 
-    def _updateUserInfos(self, config):
-        self._users = []
-        if config.has_section('users'):
-            groups = []
-            if config.has_section('groups'):
-                groups = config.items('groups')
+        # Test the effective permissions now!
+        return (effective_perms & perm) != 0
 
-            for user in config.items('users'):
-                user_info = {
-                    'username': user[0],
-                    'password': user[1],
-                    'groups': []}
-                for group in groups:
-                    users_in_group = [
-                        u.strip()
-                        for u in re.split(r'[ ,;]', group[1])]
-                    if user[0] in users_in_group:
-                        user_info['groups'].append(group[0])
-                self._users.append(user_info)
+    def _getPermissions(self, perm_or_role):
+        role_info = self._roles.get(perm_or_role)
+        if role_info is not None:
+            return (role_info.allows & ~role_info.denies)
+        p_bit = PERM_NAMES.get(perm_or_role)
+        if p_bit is not None:
+            return p_bit
+        raise InvalidPermissionError(
+            "'%s' is not a valid permission or role." % perm_or_role)
 
-    def _createUser(self, user_info):
-        user = User(user_info['username'], user_info['password'])
-        user.groups = list(user_info['groups'])
-        return user
+    def _createUser(self, name, info):
+        u = User(name, info.password)
+        u.permissions = info.flattened_perms
+        u.groups += info.flattened_lineage
+        return u
--- a/wikked/commonroutes.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/commonroutes.py	Sun Jan 07 11:11:04 2018 -0800
@@ -5,8 +5,7 @@
 from werkzeug.wsgi import wrap_file
 from wikked.web import app, get_wiki
 from wikked.webimpl import (
-    get_page_or_raise, url_from_viewarg,
-    CHECK_FOR_READ, mimetype_map)
+    get_page_or_raise, url_from_viewarg, mimetype_map)
 
 
 @app.route('/pagefiles/<path:url>')
@@ -17,7 +16,7 @@
         replace('\\', '/').\
         rstrip('/')
     page = get_page_or_raise(wiki, page_url, fields=['path'],
-                             check_perms=(user, CHECK_FOR_READ))
+                             check_perms=(user, 'read'))
     # If no exception was thrown, we're good for reading the file.
 
     path_no_ext, _ = os.path.splitext(page.path)
--- a/wikked/page.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/page.py	Sun Jan 07 11:11:04 2018 -0800
@@ -8,14 +8,19 @@
 logger = logging.getLogger(__name__)
 
 
-def get_meta_value(meta, key, first=False):
+class UnexpectedMultipleMetaValuesError(Exception):
+    pass
+
+
+def get_meta_value(meta, key, *, is_single=False):
     value = meta.get(key)
-    if value is not None and isinstance(value, list):
-        l = len(value)
-        if l == 0:
-            return None
-        if l == 1 or first:
-            return value[0]
+    if isinstance(value, list):
+        if is_single:
+            lv = len(value)
+            if lv == 0:
+                return None
+            if lv == 1:
+                return value[0]
         return value
     return value
 
@@ -112,15 +117,15 @@
     def getFormattedText(self):
         return self._data.formatted_text
 
-    def getMeta(self, name=None, first=False):
+    def getMeta(self, name=None, is_single=False):
         if name is None:
             return self._data.ext_meta
-        return get_meta_value(self._data.ext_meta, name, first)
+        return get_meta_value(self._data.ext_meta, name, is_single=is_single)
 
-    def getLocalMeta(self, name=None, first=False):
+    def getLocalMeta(self, name=None, is_single=False):
         if name is None:
             return self._data.local_meta
-        return get_meta_value(self._data.local_meta, name, first)
+        return get_meta_value(self._data.local_meta, name, is_single=is_single)
 
     def getLocalLinks(self):
         return self._data.local_links
--- a/wikked/resolver.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/resolver.py	Sun Jan 07 11:11:04 2018 -0800
@@ -400,7 +400,7 @@
             return page.text
 
         if re_wiki_query_local_meta.match(stripped_value):
-            meta = self.page.getLocalMeta(stripped_value)
+            meta = self.page.getLocalMeta(stripped_value, is_single=True)
             if with_url:
                 return (None, meta)
             return meta
--- a/wikked/templates/error-unauthorized.html	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/templates/error-unauthorized.html	Sun Jan 07 11:11:04 2018 -0800
@@ -2,13 +2,13 @@
 {% block content %}
 <article>
     <header>
-        <h1>{{error|default("You're not authorized for this")}}</h1>
+        <h1>{{error|default("User Permission Error")}}</h1>
     </header>
     <section>
         {%if error_details%}
         <p>{{error_details}}</p>
         {%else%}
-        <p>The page you're trying to access is protected.</p>
+        <p>You don't have permission for this.</p>
         {%endif%}
         {%if auth.is_logged_in%}
         <p>User <a href="{{auth.url_profile}}">{{auth.username}}</a> is not authorized for this. Please <a href="/login">log into a different account</a> to have access to it.</p>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/templates/error.html	Sun Jan 07 11:11:04 2018 -0800
@@ -0,0 +1,11 @@
+{% extends 'index.html' %}
+{% block content %}
+<article>
+    <header>
+        <h1>{{error}}</h1>
+    </header>
+    <section>
+        <p>{{error_details}}</p>
+    </section>
+</article>
+{% endblock %}
--- a/wikked/templates/page_error.html	Sun Jan 07 11:09:30 2018 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-<div class="wiki-error">{{message}}</div>
--- a/wikked/utils.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/utils.py	Sun Jan 07 11:11:04 2018 -0800
@@ -131,10 +131,10 @@
     items = list(meta.items())
     for k, v in items:
         if isinstance(v, list):
-            l = len(v)
-            if l == 0:
+            lv = len(v)
+            if lv == 0:
                 del meta[k]
-            elif l == 1:
+            elif lv == 1:
                 meta[k] = v[0]
     return meta
 
--- a/wikked/views/__init__.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/__init__.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,65 +1,7 @@
 import urllib.parse
-import functools
-from flask import request, render_template, url_for
+from flask import request, url_for
 from flask.ext.login import current_user
 from wikked.utils import get_url_folder
-from wikked.web import app, get_wiki
-from wikked.webimpl import PermissionError
-
-
-def show_unauthorized_error(error=None, error_details=None, tpl_name=None):
-    if error is not None:
-        error = str(error)
-
-    data = {}
-    if error:
-        data['error'] = error
-    if error_details:
-        data['error_details'] = error_details
-
-    add_auth_data(data)
-    add_navigation_data(None, data)
-    tpl_name = tpl_name or 'error-unauthorized.html'
-    return render_template(tpl_name, **data)
-
-
-def errorhandling_ui(f):
-    @functools.wraps(f)
-    def wrapper(*args, **kwargs):
-        try:
-            return f(*args, **kwargs)
-        except PermissionError as ex:
-            return show_unauthorized_error(ex)
-    return wrapper
-
-
-def errorhandling_ui2(tpl_name):
-    def decorator(f):
-        @functools.wraps(f)
-        def wrapper(*args, **kwargs):
-            try:
-                return f(*args, **kwargs)
-            except PermissionError as ex:
-                return show_unauthorized_error(ex, tpl_name=tpl_name)
-        return wrapper
-    return decorator
-
-
-def requires_auth(group):
-    def decorator(f):
-        @functools.wraps(f)
-        def wrapper(*args, **kwargs):
-            wiki = get_wiki()
-            if not wiki.auth.hasPermission(group, current_user.get_id()):
-                return show_unauthorized_error()
-            return f(*args, **kwargs)
-        return wrapper
-    return decorator
-
-
-def requires_reader_auth(f):
-    decorator = requires_auth('readers')
-    return decorator(f)
 
 
 def add_auth_data(data):
@@ -69,7 +11,6 @@
         data['auth'] = {
                 'is_logged_in': True,
                 'username': username,
-                'is_admin': current_user.is_admin(),
                 'url_logout': '/logout',
                 'url_profile': '/read/%s' % user_page_url
                 }
--- a/wikked/views/admin.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/admin.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,61 +1,12 @@
 import urllib.parse
-from flask import url_for, request, redirect, render_template
-from flask.ext.login import login_user, logout_user, current_user
-from wikked.views import (
-    add_auth_data, add_navigation_data, requires_reader_auth)
+from flask import url_for, render_template
+from wikked.views import add_auth_data, add_navigation_data
 from wikked.web import app, get_wiki
-
-
-@app.route('/login', methods=['GET', 'POST'])
-def login():
-    wiki = get_wiki()
-
-    data = {}
-    add_auth_data(data)
-    add_navigation_data(
-            None, data,
-            raw_url='/api/user/login')
-
-    if request.method == 'GET':
-        if current_user.is_authenticated():
-            data['already_logged_in'] = True
-            return render_template('logout.html', **data)
-        else:
-            return render_template('login.html', **data)
-
-    if request.method == 'POST':
-        username = request.form.get('username')
-        password = request.form.get('password')
-        remember = request.form.get('remember')
-        back_url = request.form.get('back_url')
-
-        user = wiki.auth.getUser(username)
-        if user is not None and app.bcrypt:
-            if app.bcrypt.check_password_hash(user.password, password):
-                login_user(user, remember=bool(remember))
-                return redirect(back_url or '/')
-
-        data['has_error'] = True
-        return render_template('login.html', **data)
-
-
-@app.route('/logout', methods=['GET', 'POST'])
-def logout():
-    if request.method == 'GET':
-        data = {}
-        add_auth_data(data)
-        add_navigation_data(
-                None, data,
-                raw_url='/api/user/logout')
-        return render_template('logout.html', **data)
-
-    if request.method == 'POST':
-        logout_user()
-        return redirect('/')
+from wikked.webimpl.decorators import requires_permission
 
 
 @app.route('/special/users')
-@requires_reader_auth
+@requires_permission('users')
 def special_users():
     wiki = get_wiki()
 
--- a/wikked/views/edit.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/edit.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,26 +1,21 @@
 from flask import abort, redirect, url_for, request, render_template
 from flask.ext.login import current_user
-from wikked.views import (
-        errorhandling_ui2, show_unauthorized_error,
-        add_auth_data, add_navigation_data)
+from wikked.views import add_auth_data, add_navigation_data
 from wikked.web import app, get_wiki
 from wikked.webimpl import url_from_viewarg
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.edit import (
     get_edit_page, do_edit_page, preview_edited_page, do_upload_file)
 
 
-@app.route('/create/', methods=['GET'])
+@app.route('/create', methods=['GET'])
 def create_page_at_root():
     return create_page('/')
 
 
 @app.route('/create/<path:url_folder>')
+@requires_permission('create')
 def create_page(url_folder):
-    wiki = get_wiki()
-    if not wiki.auth.hasPermission('writers', current_user.get_id()):
-        return show_unauthorized_error(
-                error="You're not authorized to create new pages.")
-
     title_hint = ((url_folder or '') + '/New Page').lstrip('/')
     data = {
             'is_new': True,
@@ -38,25 +33,21 @@
 
 
 @app.route('/create', methods=['POST'])
+@requires_permission('create')
 def create_page_postback():
-    wiki = get_wiki()
-    if not wiki.auth.hasPermission('writers', current_user.get_id()):
-        return show_unauthorized_error(
-                error="You're not authorized to create new pages.")
-
     url = request.form['title']
     return edit_page(url)
 
 
 @app.route('/edit', methods=['POST'])
-@errorhandling_ui2('error-unauthorized-edit.html')
+@requires_permission('edit')
 def edit_new_page():
     url = request.form['title']
     return edit_page(url)
 
 
 @app.route('/edit/<path:url>', methods=['GET', 'POST'])
-@errorhandling_ui2('error-unauthorized-edit.html')
+@requires_permission('edit')
 def edit_page(url):
     wiki = get_wiki()
     user = current_user.get_id()
@@ -104,7 +95,7 @@
 
 
 @app.route('/upload', methods=['GET', 'POST'])
-@errorhandling_ui2('error-unauthorized-upload.html')
+@requires_permission('create')
 def upload_file():
     p = request.args.get('p')
     data = {
--- a/wikked/views/error.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/error.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,30 +1,60 @@
-from flask import jsonify
+from flask import request, jsonify, render_template
 from wikked.scm.base import SourceControlError
+from wikked.views import add_auth_data, add_navigation_data
 from wikked.web import app
-from wikked.webimpl import PermissionError
+from wikked.webimpl import UserPermissionError
+
+
+def _render_error(error=None, error_details=None, tpl_name=None):
+    if error is not None:
+        error = str(error)
+
+    data = {}
+    if error:
+        data['error'] = error
+    if error_details:
+        data['error_details'] = error_details
+
+    add_auth_data(data)
+    add_navigation_data(None, data)
+    tpl_name = tpl_name or 'error.html'
+    return render_template(tpl_name, **data)
+
+
+def _jsonify_error(error_type='error', error=None, error_details=None,
+                   error_code=500):
+    resp = {
+            'error': {
+                'type': error_type,
+                'message': error
+                }
+            }
+    if error_details:
+        resp['error'].update(error_details)
+    return jsonify(resp), error_code
 
 
 @app.errorhandler(SourceControlError)
 def handle_source_control_error(error):
     app.log_exception(error)
-    resp = {
-            'error': {
-                'type': 'source_control',
-                'operation': error.operation,
-                'message': error.message
-                }
-            }
-    return jsonify(resp), 500
+    if request.path.startswith('/api/'):
+        return _jsonify_error('source_control',
+                              error.message,
+                              {'operation': error.operation})
+    else:
+        return _render_error("Source Control Error", error.message)
 
 
-@app.errorhandler(PermissionError)
+@app.errorhandler(UserPermissionError)
 def handle_permission_error(error):
-    app.log_exception(error)
-    resp = {
-            'error': {
-                'type': 'permission',
-                'message': str(error)
-                }
-            }
-    return jsonify(resp), 403
-
+    if request.path.startswith('/api/'):
+        return _jsonify_error('user_permission',
+                              str(error))
+    else:
+        perms = error.perm
+        tpl_name = 'error-unauthorized.html'
+        if 'edit' in perms or 'create' in perms:
+            tpl_name = 'error-unauthorized-edit.html'
+        elif 'upload' in perms:
+            tpl_name = 'error-unauthorized-upload.html'
+        return _render_error("User Permission Error", str(error), tpl_name)
--- a/wikked/views/history.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/history.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,18 +1,17 @@
 import urllib.parse
 from flask import request, abort, render_template
 from flask.ext.login import current_user
-from wikked.views import (
-        errorhandling_ui, requires_reader_auth,
-        add_auth_data, add_navigation_data)
+from wikked.views import add_auth_data, add_navigation_data
 from wikked.web import app, get_wiki
 from wikked.webimpl import url_from_viewarg
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.history import (
         get_site_history, get_page_history,
         read_page_rev, diff_revs, diff_page_revs)
 
 
 @app.route('/special/history')
-@requires_reader_auth
+@requires_permission('wikihistory')
 def site_history():
     wiki = get_wiki()
     user = current_user.get_id()
@@ -29,7 +28,7 @@
 
 
 @app.route('/hist/<path:url>')
-@errorhandling_ui
+@requires_permission('history')
 def page_history(url):
     wiki = get_wiki()
     user = current_user.get_id()
@@ -44,7 +43,7 @@
 
 
 @app.route('/rev/<path:url>')
-@errorhandling_ui
+@requires_permission('history')
 def page_rev(url):
     rev = request.args.get('rev')
     if rev is None:
@@ -67,7 +66,7 @@
 
 
 @app.route('/diff/<path:url>')
-@errorhandling_ui
+@requires_permission('history')
 def diff_page(url):
     rev1 = request.args.get('rev1')
     rev2 = request.args.get('rev2')
@@ -95,7 +94,7 @@
 
 
 @app.route('/diff_rev/<rev>')
-@errorhandling_ui
+@requires_permission('history')
 def diff_revision(rev):
     wiki = get_wiki()
     user = current_user.get_id()
--- a/wikked/views/read.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/read.py	Sun Jan 07 11:11:04 2018 -0800
@@ -3,10 +3,11 @@
     render_template, request, abort)
 from flask.ext.login import current_user
 from wikked.utils import split_page_url, PageNotFoundError
-from wikked.views import add_auth_data, add_navigation_data, errorhandling_ui
+from wikked.views import add_auth_data, add_navigation_data
 from wikked.web import app, get_wiki
 from wikked.webimpl import (
     url_from_viewarg, make_page_title, RedirectNotFoundError)
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.read import (
     read_page, get_incoming_links)
 from wikked.webimpl.special import get_search_results
@@ -33,7 +34,6 @@
 
 
 @app.route('/read/<path:url>')
-@errorhandling_ui
 def read(url):
     wiki = get_wiki()
     url = url_from_viewarg(url)
@@ -62,7 +62,7 @@
 
 
 @app.route('/search')
-@errorhandling_ui
+@requires_permission('search')
 def search():
     query = request.args.get('q')
     if query is None or query == '':
@@ -85,7 +85,7 @@
 
 
 @app.route('/inlinks/<path:url>')
-@errorhandling_ui
+@requires_permission('read')
 def incoming_links(url):
     wiki = get_wiki()
     user = current_user.get_id()
--- a/wikked/views/special.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/views/special.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,9 +1,8 @@
 from flask import request, redirect, url_for, render_template, abort
 from flask.ext.login import current_user
-from wikked.views import (
-        requires_auth, requires_reader_auth,
-        add_auth_data, add_navigation_data)
+from wikked.views import add_auth_data, add_navigation_data
 from wikked.web import app, get_wiki
+from wikked.webimpl.decorators import requires_permission
 from wikked.webimpl.special import (
         get_orphans, get_broken_redirects, get_double_redirects,
         get_dead_ends, get_broken_links, get_wanted_pages)
@@ -87,7 +86,7 @@
 
 
 @app.route('/special')
-@requires_reader_auth
+@requires_permission('read')
 def special_pages_dashboard():
     data = {
             'is_special_page': True,
@@ -136,42 +135,42 @@
 
 
 @app.route('/special/list/orphans')
-@requires_reader_auth
+@requires_permission('read')
 def special_list_orphans():
     return call_api('orphans', get_orphans,
                     raw_url='/api/orphans')
 
 
 @app.route('/special/list/broken-redirects')
-@requires_reader_auth
+@requires_permission('read')
 def special_list_broken_redirects():
     return call_api('broken-redirects', get_broken_redirects,
                     raw_url='/api/broken-redirects')
 
 
 @app.route('/special/list/double-redirects')
-@requires_reader_auth
+@requires_permission('read')
 def special_list_double_redirects():
     return call_api('double-redirects', get_double_redirects,
                     raw_url='/api/double-redirects')
 
 
 @app.route('/special/list/dead-ends')
-@requires_reader_auth
+@requires_permission('read')
 def special_list_dead_ends():
     return call_api('dead-ends', get_dead_ends,
                     raw_url='/api/dead-ends')
 
 
 @app.route('/special/list/broken-links')
-@requires_reader_auth
+@requires_permission('read')
 def special_list_broken_links():
     return call_api('broken-links', get_broken_links,
                     raw_url='/api/broken-links')
 
 
 @app.route('/special/list/wanted-pages')
-@requires_reader_auth
+@requires_permission('read')
 def special_list_wanted_pages():
     return call_api('wanted-pages', get_wanted_pages,
                     raw_url='/api/wanted-pages',
@@ -179,7 +178,7 @@
 
 
 @app.route('/special/list-refresh', methods=['POST'])
-@requires_auth('administrators')
+@requires_permission('listrefresh')
 def special_list_refresh():
     list_name = request.form.get('list_name')
     postback_name = request.form.get('postback')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/views/user.py	Sun Jan 07 11:11:04 2018 -0800
@@ -0,0 +1,52 @@
+from flask import request, redirect, render_template
+from flask.ext.login import login_user, logout_user, current_user
+from wikked.views import add_auth_data, add_navigation_data
+from wikked.web import app, get_wiki
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    wiki = get_wiki()
+
+    data = {}
+    add_auth_data(data)
+    add_navigation_data(
+            None, data,
+            raw_url='/api/user/login')
+
+    if request.method == 'GET':
+        if current_user.is_authenticated():
+            data['already_logged_in'] = True
+            return render_template('logout.html', **data)
+        else:
+            return render_template('login.html', **data)
+
+    if request.method == 'POST':
+        username = request.form.get('username')
+        password = request.form.get('password')
+        remember = request.form.get('remember')
+        back_url = request.form.get('back_url')
+
+        user = wiki.auth.getUser(username)
+        if user is not None and app.bcrypt:
+            if app.bcrypt.check_password_hash(user.password, password):
+                login_user(user, remember=bool(remember))
+                return redirect(back_url or '/')
+
+        data['has_error'] = True
+        return render_template('login.html', **data)
+
+
+@app.route('/logout', methods=['GET', 'POST'])
+def logout():
+    if request.method == 'GET':
+        data = {}
+        add_auth_data(data)
+        add_navigation_data(
+                None, data,
+                raw_url='/api/user/logout')
+        return render_template('logout.html', **data)
+
+    if request.method == 'POST':
+        logout_user()
+        return redirect('/')
--- a/wikked/web.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/web.py	Sun Jan 07 11:11:04 2018 -0800
@@ -78,12 +78,12 @@
 
 # Customize logging.
 if app.config['DEBUG']:
-    l = logging.getLogger('wikked')
-    l.setLevel(logging.DEBUG)
+    lg = logging.getLogger('wikked')
+    lg.setLevel(logging.DEBUG)
 
 if app.config['SQL_DEBUG']:
-    l = logging.getLogger('sqlalchemy')
-    l.setLevel(logging.DEBUG)
+    lg = logging.getLogger('sqlalchemy')
+    lg.setLevel(logging.DEBUG)
 
 app.logger.debug("Creating Flask application...")
 
@@ -116,6 +116,7 @@
     app.logger.debug("Uncaching all pages because %s was edited." % url)
     wiki.db.uncachePages(except_url=url, only_required=True)
 
+
 app.wiki_params.wiki_updater = uncaching_wiki_updater
 
 
@@ -178,12 +179,14 @@
 import wikked.api.history     # NOQA
 import wikked.api.read        # NOQA
 import wikked.api.special     # NOQA
+import wikked.api.user        # NOQA
 import wikked.views.admin     # NOQA
 import wikked.views.edit      # NOQA
 import wikked.views.error     # NOQA
 import wikked.views.history   # NOQA
 import wikked.views.read      # NOQA
 import wikked.views.special   # NOQA
+import wikked.views.user      # NOQA
 
 
 # Async wiki update.
--- a/wikked/webimpl/__init__.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/webimpl/__init__.py	Sun Jan 07 11:11:04 2018 -0800
@@ -3,6 +3,7 @@
 import logging
 import datetime
 import urllib.parse
+from wikked.auth import PERM_READ, PERM_EDIT, PERM_NAMES
 from wikked.utils import (
         get_absolute_url, PageNotFoundError, split_page_url, is_endpoint_url)
 from wikked.web import app
@@ -11,10 +12,6 @@
 logger = logging.getLogger(__name__)
 
 
-CHECK_FOR_READ = 1
-CHECK_FOR_WRITE = 2
-
-
 class CircularRedirectError(Exception):
     def __init__(self, url, visited):
         super(CircularRedirectError, self).__init__(
@@ -31,8 +28,10 @@
         self.url = not_found
 
 
-class PermissionError(Exception):
-    pass
+class UserPermissionError(Exception):
+    def __init__(self, perm, message):
+        super().__init__(message)
+        self.perm = perm
 
 
 def url_from_viewarg(url):
@@ -49,8 +48,7 @@
     return (None, '/' + path)
 
 
-def get_page_or_raise(wiki, url, fields=None,
-                      check_perms=None):
+def get_page_or_raise(wiki, url, fields=None, check_perms=None):
     auto_reload = app.config.get('WIKI_AUTO_RELOAD', False)
     if auto_reload is True and fields is not None:
         if 'path' not in fields:
@@ -64,8 +62,8 @@
             fields.append('is_resolved')
 
     if check_perms is not None and fields is not None:
-        if 'meta' not in fields:
-            fields.append('meta')
+        if 'local_meta' not in fields:
+            fields.append('local_meta')
 
     page = wiki.getPage(url, fields=fields)
 
@@ -86,11 +84,17 @@
             page = wiki.getPage(url, fields=fields)
 
     if check_perms is not None:
-        user, mode = check_perms
-        if mode == CHECK_FOR_READ and not is_page_readable(page, user):
-            raise PermissionError()
-        elif mode == CHECK_FOR_WRITE and not is_page_writable(page, user):
-            raise PermissionError()
+        user, modes = check_perms
+        has_page_perm = page.wiki.auth.hasPagePermission
+        for mode in modes.split(','):
+            if not has_page_perm(page, user, PERM_NAMES[mode]):
+                if mode == 'read':
+                    msg = "You don't have permissions to read this page."
+                elif mode == 'edit':
+                    msg = "You don't have permissions to edit this page."
+                else:
+                    msg = "You don't have the '%s' permission." % mode
+                raise UserPermissionError(mode, msg)
 
     return page
 
@@ -102,12 +106,12 @@
         return None
 
 
-def is_page_readable(page, user):
-    return page.wiki.auth.isPageReadable(page, user)
+def is_page_readable(page, username):
+    return page.wiki.auth.hasPagePermission(page, username, PERM_READ)
 
 
-def is_page_writable(page, user):
-    return page.wiki.auth.isPageWritable(page, user)
+def is_page_writable(page, username):
+    return page.wiki.auth.hasPagePermission(page, username, PERM_EDIT)
 
 
 def get_page_meta(page, local_only=False):
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wikked/webimpl/decorators.py	Sun Jan 07 11:11:04 2018 -0800
@@ -0,0 +1,25 @@
+import functools
+from flask.ext.login import current_user
+from wikked.auth import PERM_NAMES
+from wikked.web import get_wiki
+from wikked.webimpl import UserPermissionError
+
+
+def requires_permission(perm):
+    if isinstance(perm, str):
+        perm = [perm]
+
+    p_bit = 0
+    for p in perm:
+        p_bit |= PERM_NAMES[p]
+
+    def decorator(f):
+        @functools.wraps(f)
+        def wrapper(*args, **kwargs):
+            wiki = get_wiki()
+            if not wiki.auth.hasPermission(current_user.get_id(), p_bit):
+                raise UserPermissionError(
+                    perm, "You don't have permission for this.")
+            return f(*args, **kwargs)
+        return wrapper
+    return decorator
--- a/wikked/webimpl/edit.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/webimpl/edit.py	Sun Jan 07 11:11:04 2018 -0800
@@ -7,7 +7,6 @@
 from wikked.resolver import PageResolver
 from wikked.utils import PageNotFoundError
 from wikked.webimpl import (
-        CHECK_FOR_WRITE,
         get_page_or_raise, get_page_meta, make_page_title)
 
 
@@ -46,7 +45,7 @@
     page = None
     try:
         page = get_page_or_raise(wiki, url,
-                                 check_perms=(user, CHECK_FOR_WRITE))
+                                 check_perms=(user, 'edit'))
     except PageNotFoundError:
         # Only catch errors about the page not existing. Permission
         # errors still go through.
@@ -80,7 +79,7 @@
 def do_edit_page(wiki, user, url, text, author=None, message=None):
     try:
         get_page_or_raise(wiki, url,
-                          check_perms=(user, CHECK_FOR_WRITE))
+                          check_perms=(user, 'edit'))
     except PageNotFoundError:
         # Only catch errors about the page not existing. Permission
         # errors still go through.
--- a/wikked/webimpl/history.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/webimpl/history.py	Sun Jan 07 11:11:04 2018 -0800
@@ -3,12 +3,8 @@
 from pygments import highlight
 from pygments.formatters import get_formatter_by_name
 from pygments.lexers import get_lexer_by_name
-from wikked.page import PageLoadingError
-from wikked.scm.base import ACTION_NAMES
-from wikked.utils import PageNotFoundError
-from wikked.webimpl import (
-        CHECK_FOR_READ,
-        is_page_readable, get_page_meta, get_page_or_raise)
+from wikked.scm.base import ACTION_NAMES, ACTION_ADD, ACTION_EDIT
+from wikked.webimpl import get_page_meta, get_page_or_raise
 
 
 def get_history_data(wiki, user, history, needs_files=False):
@@ -27,24 +23,19 @@
         if needs_files:
             rev_data['pages'] = []
             for f in rev.files:
-                url = None
-                path = os.path.join(wiki.root, f['path'])
-                try:
-                    page = wiki.db.getPage(path=path)
-                    # Hide pages that the user can't see.
-                    if not is_page_readable(page, user):
-                        continue
-                    url = page.url
-                except PageNotFoundError:
-                    pass
-                except PageLoadingError:
-                    pass
-                if not url:
-                    url = os.path.splitext(f['path'])[0]
-                rev_data['pages'].append({
-                    'url': url,
-                    'action': ACTION_NAMES[f['action']]
-                    })
+                path = os.path.join(wiki.root, f.path)
+                page_info = wiki.fs.getPageInfo(path)
+                action_name = ACTION_NAMES[f.action]
+                if page_info is not None:
+                    rev_data['pages'].append({
+                        'url': page_info.url,
+                        'is_add_or_edit': (f.action == ACTION_ADD or
+                                           f.action == ACTION_EDIT),
+                        'action': action_name})
+                else:
+                    rev_data['pages'].append({
+                        'path': f.path,
+                        'action': action_name})
             rev_data['num_pages'] = len(rev_data['pages'])
             if len(rev_data['pages']) > 0:
                 hist_data.append(rev_data)
@@ -61,7 +52,7 @@
 
 
 def get_page_history(wiki, user, url):
-    page = get_page_or_raise(wiki, url, check_perms=(user, CHECK_FOR_READ))
+    page = get_page_or_raise(wiki, url, check_perms=(user, 'read,history'))
     history = page.getHistory()
     hist_data = get_history_data(wiki, user, history)
     result = {'url': url, 'meta': get_page_meta(page), 'history': hist_data}
@@ -69,7 +60,7 @@
 
 
 def read_page_rev(wiki, user, url, rev):
-    page = get_page_or_raise(wiki, url, check_perms=(user, CHECK_FOR_READ))
+    page = get_page_or_raise(wiki, url, check_perms=(user, 'read,history'))
     page_rev = page.getRevision(rev)
     meta = dict(get_page_meta(page, True), rev=rev)
     result = {'meta': meta, 'text': page_rev}
@@ -77,7 +68,7 @@
 
 
 def diff_page_revs(wiki, user, url, rev1, rev2=None, raw=False):
-    page = get_page_or_raise(wiki, url, check_perms=(user, CHECK_FOR_READ))
+    page = get_page_or_raise(wiki, url, check_perms=(user, 'read,history'))
     diff = page.getDiff(rev1, rev2)
     if not raw:
         lexer = get_lexer_by_name('diff')
@@ -108,4 +99,3 @@
             'message': message
             }
     wiki.revertPage(url, page_fields)
-
--- a/wikked/webimpl/read.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/webimpl/read.py	Sun Jan 07 11:11:04 2018 -0800
@@ -1,7 +1,6 @@
 import os.path
 import urllib.parse
 from wikked.webimpl import (
-        CHECK_FOR_READ,
         get_redirect_target, get_page_meta, get_page_or_raise)
 from wikked.utils import split_page_url, PageNotFoundError
 
@@ -14,7 +13,7 @@
         page, visited_paths = get_redirect_target(
                 wiki, path,
                 fields=['url', 'path', 'title', 'text', 'meta'],
-                check_perms=(user, CHECK_FOR_READ),
+                check_perms=(user, 'read'),
                 first_only=no_redirect)
 
         if no_redirect:
@@ -35,7 +34,7 @@
         info_page = get_page_or_raise(
                 wiki, meta_page_url,
                 fields=['url', 'path', 'title', 'text', 'meta'],
-                check_perms=(user, CHECK_FOR_READ))
+                check_perms=(user, 'read'))
     except PageNotFoundError:
         # Let permissions errors go through, but if the info page is not
         # found that's OK.
@@ -49,7 +48,7 @@
             info_page = get_page_or_raise(
                     wiki, endpoint_info.default,
                     fields=['url', 'path', 'title', 'text', 'meta'],
-                    check_perms=(user, CHECK_FOR_READ))
+                    check_perms=(user, 'read'))
 
     ext = None
     if info_page is not None:
@@ -113,14 +112,14 @@
 def get_incoming_links(wiki, user, url):
     page = get_page_or_raise(
             wiki, url,
-            check_perms=(user, CHECK_FOR_READ),
+            check_perms=(user, 'read'),
             fields=['url', 'title', 'meta'])
     links = []
     for link in page.getIncomingLinks():
         try:
             other = get_page_or_raise(
                     wiki, link,
-                    check_perms=(user, CHECK_FOR_READ),
+                    check_perms=(user, 'read'),
                     fields=['url', 'title', 'meta'])
             links.append(get_page_meta(other))
         except PageNotFoundError:
@@ -133,14 +132,14 @@
 def get_outgoing_links(wiki, user, url):
     page = get_page_or_raise(
             wiki, url,
-            check_perms=(user, CHECK_FOR_READ),
+            check_perms=(user, 'read'),
             fields=['url', 'title', 'links'])
     links = []
     for link in page.links:
         try:
             other = get_page_or_raise(
                     wiki, link,
-                    check_perms=(user, CHECK_FOR_READ),
+                    check_perms=(user, 'read'),
                     fields=['url', 'title', 'meta'])
             links.append(get_page_meta(other))
         except PageNotFoundError:
--- a/wikked/webimpl/special.py	Sun Jan 07 11:09:30 2018 -0800
+++ b/wikked/webimpl/special.py	Sun Jan 07 11:11:04 2018 -0800
@@ -3,11 +3,10 @@
 from wikked.page import WantedPage
 from wikked.utils import get_absolute_url
 from wikked.webimpl import (
-        CHECK_FOR_READ,
         get_page_meta, get_page_or_raise, make_page_title,
         is_page_readable, get_redirect_target,
         get_or_build_pagelist, get_generic_pagelist_builder,
-        CircularRedirectError, RedirectNotFoundError)
+        UserPermissionError, CircularRedirectError, RedirectNotFoundError)
 
 
 def build_pagelist_view_data(pages, user):
@@ -183,7 +182,7 @@
 
 
 def list_pages(wiki, user, url=None):
-    pages = list(filter(is_page_readable, wiki.getPages(url)))
+    pages = [p for p in wiki.getPages(url) if is_page_readable(p, user)]
     page_metas = [get_page_meta(page) for page in pages]
     result = {'path': url, 'pages': list(page_metas)}
     return result
@@ -195,8 +194,8 @@
     for h in hits:
         try:
             get_page_or_raise(wiki, h.url,
-                              check_perms=(user, CHECK_FOR_READ))
-        except PermissionError:
+                              check_perms=(user, 'read'))
+        except UserPermissionError:
             continue
 
         readable_hits.append({
@@ -217,8 +216,8 @@
     for h in hits:
         try:
             get_page_or_raise(wiki, h.url,
-                              check_perms=(user, CHECK_FOR_READ))
-        except PermissionError:
+                              check_perms=(user, 'read'))
+        except UserPermissionError:
             continue
 
         readable_hits.append({'url': h.url, 'title': h.title})
@@ -228,5 +227,3 @@
             'hit_count': len(readable_hits),
             'hits': readable_hits}
     return result
-
-