aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2022-07-06 00:04:54 +0200
committerDaniel Schadt <kingdread@gmx.de>2022-07-06 00:04:54 +0200
commit2a910d13e758ff5c928b59aa8299ee1c96c3227b (patch)
tree6b63542105712cb9270892a20d24483f82574f2a
parent03b08ca7454e8ff60b26c0bbe79d4bee89b45eaf (diff)
downloadfietsboek-2a910d13e758ff5c928b59aa8299ee1c96c3227b.tar.gz
fietsboek-2a910d13e758ff5c928b59aa8299ee1c96c3227b.tar.bz2
fietsboek-2a910d13e758ff5c928b59aa8299ee1c96c3227b.zip
move track authorization to ACLs
This cuts down on the code duplication and makes sure that we use the same algorithm everywhere. It also keeps the view code cleaner.
-rw-r--r--fietsboek/models/track.py64
-rw-r--r--fietsboek/models/user.py14
-rw-r--r--fietsboek/security.py30
-rw-r--r--fietsboek/views/detail.py23
-rw-r--r--fietsboek/views/edit.py10
5 files changed, 89 insertions, 52 deletions
diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py
index e34805f..0235231 100644
--- a/fietsboek/models/track.py
+++ b/fietsboek/models/track.py
@@ -33,6 +33,9 @@ from sqlalchemy.orm import relationship
from pyramid.httpexceptions import HTTPNotFound
from pyramid.i18n import TranslationString as _
+from pyramid.authorization import (
+ Allow, Everyone, Authenticated, ALL_PERMISSIONS, ACLHelper, ACLAllowed,
+)
from markupsafe import Markup
from babel.numbers import format_decimal
@@ -170,6 +173,41 @@ class Track(Base):
raise HTTPNotFound()
return track
+ def __acl__(self):
+ # Basic ACL: Permissions for the admin, the owner and the share link
+ acl = [
+ (Allow, 'group:admins', ALL_PERMISSIONS),
+ (Allow, f'user:{self.owner_id}',
+ ['track.view', 'track.edit', 'track.unshare', 'track.comment']),
+ (Allow, f'secret:{self.link_secret}', 'track.view'),
+ ]
+
+ # Tagged people may always see the track
+ for tagged in self.tagged_people:
+ acl.append((Allow, f'user:{tagged.id}', ['track.view', 'track.comment']))
+
+ if self.visibility == Visibility.PUBLIC:
+ acl.append((Allow, Everyone, 'track.view'))
+ acl.append((Allow, Authenticated, 'track.comment'))
+ elif self.visibility == Visibility.LOGGED_IN:
+ acl.append((Allow, Authenticated, ['track.view', 'track.comment']))
+ elif self.visibility == Visibility.FRIENDS:
+ acl.extend(
+ (Allow, f'user:{friend.id}', ['track.view', 'track.comment'])
+ for friend in self.owner.get_friends()
+ )
+ elif self.visibility == Visibility.FRIENDS_TAGGED:
+ all_friends = (
+ friend
+ for person in chain([self.owner], self.tagged_people)
+ for friend in person.get_friends()
+ )
+ acl.extend(
+ (Allow, f'user:{friend.id}', ['track.view', 'track.comment'])
+ for friend in all_friends
+ )
+ return acl
+
# GPX files are XML files with a lot of repeated property names. Really, it
# is quite inefficient to store a whole ton of GPS points in big XML
# structs. Therefore, we add transparent gzip compression to reduce the
@@ -197,26 +235,12 @@ class Track(Base):
:return: Whether the track is visible to the current user.
:rtype: bool
"""
- # Public tracks are always visible
- if self.visibility == Visibility.PUBLIC:
- return True
- # Tracks are always visible to the owner and the tagged people
- if user == self.owner or user in self.tagged_people:
- return True
- # Alternatively, if the track is set to friends visibility and the
- # logged in user is a friend.
- if self.visibility == Visibility.FRIENDS:
- return user in self.owner.get_friends()
- if self.visibility == Visibility.FRIENDS_TAGGED:
- all_friends = {
- friend
- for person in chain([self.owner], self.tagged_people)
- for friend in person.get_friends()
- }
- return user in all_friends
- if user and self.visibility == Visibility.LOGGED_IN:
- return True
- return False
+ principals = [Everyone]
+ if user:
+ principals.append(Authenticated)
+ principals.extend(user.principals())
+ result = ACLHelper().permits(self, principals, 'track.view')
+ return isinstance(result, ACLAllowed)
def ensure_cache(self):
"""Ensure that a cached version of this track's metadata exists."""
diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py
index d70c99e..c8296c6 100644
--- a/fietsboek/models/user.py
+++ b/fietsboek/models/user.py
@@ -154,6 +154,20 @@ class User(Base):
except InvalidKey:
raise PasswordMismatch from None
+ def principals(self):
+ """Returns all principals that this user fulfills.
+
+ This does not include the ``Everyone`` and ``Authenticated``
+ principals, as those have to be checked before accessing the user.
+
+ :return: The seceurity principals that this user fulfills.
+ :rtype: list[str]
+ """
+ principals = [f'user:{self.id}']
+ if self.is_admin:
+ principals.append('group:admins')
+ return principals
+
@property
def all_tracks(self):
"""Yields all tracks in which the user participated.
diff --git a/fietsboek/security.py b/fietsboek/security.py
index dfc21e3..a5cafd4 100644
--- a/fietsboek/security.py
+++ b/fietsboek/security.py
@@ -1,6 +1,8 @@
"""Module implementing the user authentication."""
from pyramid.security import Allowed, Denied
from pyramid.authentication import SessionAuthenticationHelper
+from pyramid.authorization import ACLHelper, Everyone, Authenticated
+from pyramid.traversal import DefaultRootFactory
from sqlalchemy import select
@@ -33,15 +35,27 @@ class SecurityPolicy:
def permits(self, request, context, permission):
"""See :meth:`pyramid.interfaces.ISecurityPolicy.permits`"""
- # pylint: disable=unused-argument
identity = self.identity(request)
- if identity is None:
- return Denied('User is not signed in.')
- if permission not in ADMIN_PERMISSIONS:
- return Allowed('User is signed in.')
- if request.identity.is_admin:
- return Allowed('User is an administrator.')
- return Denied('User is not an administrator.')
+ # If the context is not there, we are on a static site that does not use ACL
+ if isinstance(context, DefaultRootFactory):
+ if identity is None:
+ return Denied('User is not signed in.')
+ if permission not in ADMIN_PERMISSIONS:
+ return Allowed('User is signed in.')
+ if identity.is_admin:
+ return Allowed('User is an administrator.')
+ return Denied('User is not an administrator.')
+
+ # If the context is there, use ACL
+ principals = [Everyone]
+ if identity is not None:
+ principals.append(Authenticated)
+ principals.extend(identity.principals())
+
+ if 'secret' in request.GET:
+ principals.append(f'secret:{request.GET["secret"]}')
+
+ return ACLHelper().permits(context, principals, permission)
def remember(self, request, userid, **kw):
"""See :meth:`pyramid.interfaces.ISecurityPolicy.remember`"""
diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py
index a1fe27f..d035a85 100644
--- a/fietsboek/views/detail.py
+++ b/fietsboek/views/detail.py
@@ -3,12 +3,13 @@ import datetime
from pyramid.view import view_config
from pyramid.response import Response
-from pyramid.httpexceptions import HTTPForbidden, HTTPFound
+from pyramid.httpexceptions import HTTPFound
from .. import models, util
-@view_config(route_name='details', renderer='fietsboek:templates/details.jinja2')
+@view_config(route_name='details', renderer='fietsboek:templates/details.jinja2',
+ permission='track.view')
def details(request):
"""Renders the detail page for a given track.
@@ -18,9 +19,6 @@ def details(request):
:rtype: pyramid.response.Response
"""
track = request.context
- if (not track.is_visible_to(request.identity)
- and request.GET.get('secret') != track.link_secret):
- return HTTPForbidden()
description = util.safe_markdown(track.description)
show_edit_link = (track.owner == request.identity)
return {
@@ -31,7 +29,7 @@ def details(request):
'description': description,
}
-@view_config(route_name='gpx', http_cache=3600)
+@view_config(route_name='gpx', http_cache=3600, permission='track.view')
def gpx(request):
"""Returns the actual GPX data from the stored track.
@@ -41,13 +39,10 @@ def gpx(request):
:rtype: pyramid.response.Response
"""
track = request.context
- if (not track.is_visible_to(request.identity)
- and request.GET.get('secret') != track.link_secret):
- return HTTPForbidden()
return Response(track.gpx_data, content_type="application/gpx+xml")
-@view_config(route_name='invalidate-share', request_method='POST')
+@view_config(route_name='invalidate-share', request_method='POST', permission='track.unshare')
def invalidate_share(request):
"""Endpoint to invalidate the share link.
@@ -57,8 +52,6 @@ def invalidate_share(request):
:rtype: pyramid.response.Response
"""
track = request.context
- if track.owner != request.identity:
- return HTTPForbidden()
track.link_secret = util.random_alphanum_string()
return HTTPFound(request.route_url('details', id=track.id))
@@ -75,7 +68,7 @@ def badge(request):
return Response(request.context.image)
-@view_config(route_name="add-comment", request_method="POST", permission="comment")
+@view_config(route_name="add-comment", request_method="POST", permission="track.comment")
def add_comment(request):
"""Endpoint to add a comment to a track.
@@ -85,10 +78,6 @@ def add_comment(request):
:rtype: pyramid.response.Response
"""
track = request.context
- if (not track.is_visible_to(request.identity)
- and request.GET.get('secret') != track.link_secret):
- return HTTPForbidden()
-
comment = models.Comment(
track=track,
author=request.identity,
diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py
index 5e247b3..5d91ea8 100644
--- a/fietsboek/views/edit.py
+++ b/fietsboek/views/edit.py
@@ -2,7 +2,7 @@
import datetime
from pyramid.view import view_config
-from pyramid.httpexceptions import HTTPForbidden, HTTPFound, HTTPBadRequest
+from pyramid.httpexceptions import HTTPFound, HTTPBadRequest
from sqlalchemy import select
@@ -11,7 +11,7 @@ from ..models.track import Visibility
@view_config(route_name='edit', renderer='fietsboek:templates/edit.jinja2',
- permission='edit', request_method='GET')
+ permission='track.edit', request_method='GET')
def edit(request):
"""Renders the edit form.
@@ -21,8 +21,6 @@ def edit(request):
:rtype: pyramid.response.Response
"""
track = request.context
- if request.identity != track.owner:
- return HTTPForbidden()
badges = request.dbsession.execute(select(models.Badge)).scalars()
badges = [(badge in track.badges, badge) for badge in badges]
return {
@@ -31,7 +29,7 @@ def edit(request):
}
-@view_config(route_name='edit', permission='edit', request_method='POST')
+@view_config(route_name='edit', permission='track.edit', request_method='POST')
def do_edit(request):
"""Endpoint for saving the edited data.
@@ -42,8 +40,6 @@ def do_edit(request):
"""
# pylint: disable=duplicate-code
track = request.context
- if request.identity != track.owner:
- return HTTPForbidden()
user_friends = request.identity.get_friends()
badges = util.retrieve_multiple(request.dbsession, models.Badge, request.params, "badge[]")