diff options
-rw-r--r-- | fietsboek/models/track.py | 64 | ||||
-rw-r--r-- | fietsboek/models/user.py | 14 | ||||
-rw-r--r-- | fietsboek/security.py | 30 | ||||
-rw-r--r-- | fietsboek/views/detail.py | 23 | ||||
-rw-r--r-- | fietsboek/views/edit.py | 10 |
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[]") |