Adding the access control¶
There are quite a number of bits and pieces of access control that need to be built into this application. One can classify all the bits and pieces into three categories:
The first category has to do with access control on Inputs.
The Edit Button next to each Address in the AddressBookView should always be displayed, but be greyed out unless the current user is allowed to edit that particular Address. On the EditAddressView itself, the TextInput for “Name” should be greyed out if the current user is not allowed to change it. (Remember, the requirements stated that you can only change the name if you’re allowed to add Addresses to the selected AddressBook.)
The next category concerns the MenuItems displayed as part of the Menu of AddressBookView.
The Menu in question contains two MenuItems: one to add an Address, and one to add a Collaborator. “Add Address” should always be shown, but should be greyed out except if the current user is allowed to add Addresses to the AddressBook being viewed. “Add Collaborator” should not even be shown, except on the user’s own AddressBook.
The last category is more sinister. A malicious user could pay attention to what the URLs look like as he navigates the application. For example, you’d notice that the URL of your own AddressBook is /address_book/3. From such an URL it is pretty obvious that it denotes the AddressBook with ID 3. Knowing (or guessing), you could change the 3 to some other number and in this way end up seeing an AddressBook that belongs to someone else even though no user interface element lead you to it.
The same sort of vulnerability is possible for the other URLs: /add_address, /edit_address and /add_collaborator.
Tests¶
Before we start though, lets add the necessary tests. The categories above are useful for explaining the implementation, but not so useful to explain the requirements of the application itself. Tests should elucidate the latter. Hence, the following tests were added:
@test(AccessFixture)
def see_other(fixture):
"""If allowed, an account may see another account's AddressBook, and could edit or add Addresses,
depending on the allowed rights."""
browser = fixture.browser
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
other_address_book.allow(account)
Address(address_book=other_address_book, email_address='[email protected]', name='Friend').save()
fixture.log_in(browser=browser, system_account=account)
browser.open('/')
browser.click(XPath.link_with_text('Address book of [email protected]'))
assert browser.is_element_present(XPath.paragraph_containing('Friend: [email protected]'))
# Case: can only see
assert not browser.is_element_enabled(XPath.link_with_text('Add address'))
assert not browser.is_element_enabled(XPath.button_labelled('Edit'))
# Case: can edit only
other_address_book.allow(account, can_edit_addresses=True, can_add_addresses=False)
browser.refresh()
assert not browser.is_element_enabled(XPath.link_with_text('Add address'))
assert browser.is_element_enabled(XPath.button_labelled('Edit'))
# Case: can add, and therefor also edit
other_address_book.allow(account, can_add_addresses=True)
browser.refresh()
assert browser.is_element_enabled(XPath.link_with_text('Add address'))
assert browser.is_element_enabled(XPath.button_labelled('Edit'))
browser.click(XPath.button_labelled('Edit'))
browser.type(XPath.input_labelled('Name'), 'Else')
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.click(XPath.button_labelled('Update'))
assert browser.is_element_present(XPath.paragraph_containing('Else: [email protected]'))
@test(AccessFixture)
def edit_other(fixture):
"""If you may only edit (not add) an address, then you may only edit the email address, not the name."""
browser = fixture.browser
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
other_address_book.allow(account, can_edit_addresses=True, can_add_addresses=True)
Address(address_book=other_address_book, email_address='[email protected]', name='Friend').save()
fixture.log_in(browser=browser, system_account=account)
browser.open('/')
browser.click(XPath.link_with_text('Address book of [email protected]'))
browser.click(XPath.button_labelled('Edit'))
# Case: may edit name
assert browser.is_element_enabled(XPath.input_labelled('Name'))
assert browser.is_element_enabled(XPath.input_labelled('Email'))
# Case: may not edit name
other_address_book.allow(account, can_edit_addresses=True, can_add_addresses=False )
browser.refresh()
assert not browser.is_element_enabled(XPath.input_labelled('Name'))
assert browser.is_element_enabled(XPath.input_labelled('Email'))
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.click(XPath.button_labelled('Update'))
assert browser.is_element_present(XPath.paragraph_containing('Friend: [email protected]'))
@test(AccessFixture)
def add_collaborator(fixture):
"""A user may add other users as collaborators to his address book, specifying the privileges in the process."""
browser = fixture.browser
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
other_account = fixture.other_account
fixture.log_in(browser=browser, system_account=account)
browser.open('/')
assert address_book not in AddressBook.address_books_visible_to(other_account)
assert not address_book.can_be_edited_by(other_account)
assert not address_book.can_be_added_to_by(other_account)
browser.click(XPath.link_with_text('Address book of [email protected]'))
browser.click(XPath.link_with_text('Add collaborator'))
browser.select(XPath.select_labelled('Choose collaborator'), '[email protected]')
browser.click(XPath.input_labelled('May add new addresses'))
browser.click(XPath.input_labelled('May edit existing addresses'))
browser.click(XPath.button_labelled('Share'))
assert fixture.is_on_address_book_page_of('[email protected]')
assert address_book in AddressBook.address_books_visible_to(other_account)
assert address_book.can_be_edited_by(other_account)
assert address_book.can_be_added_to_by(other_account)
class ViewScenarios(AccessFixture):
@scenario
def viewing_other_address_book(self):
self.other_address_book; Session.flush()
self.url = '/address_book/%s' % self.other_address_book.id
self.get = True
@scenario
def add_address_to_other_address_book(self):
self.other_address_book; Session.flush()
self.url = '/add_address/%s' % self.other_address_book.id
self.get = False
@scenario
def edit_address_in_other_address_book(self):
address = Address(address_book=self.other_address_book, email_address='[email protected]', name='Friend')
address.save()
Session.flush()
self.url = '/edit_address/%s' % address.id
self.get = True
@scenario
def add_collaborator_to_other_address_book(self):
self.other_address_book; Session.flush()
self.url = '/add_collaborator/%s' % self.other_address_book.id
self.get = True
@test(ViewScenarios)
def view_permissions(fixture):
browser = fixture.browser
account = fixture.account
fixture.log_in(browser=browser, system_account=account)
if fixture.get:
browser.open(fixture.url, status=403)
else:
browser.post(fixture.url, {}, status=403)
The code¶
Inputs¶
The access control for an Input is determined from the Field to which it is attached. When creating a Field, one can pass in two extra keyword arguments: readable and writable. These arguments are Actions: The methods they wrap are expected to return True or False to indicate whether the Field is readable or writable for current user.
If a Field is readable, but not writable for someone, an Input using it will be present, but greyed out.
If the Field is both readable and writable, the Input will be displayed and active.
If the Field is not readable and also not writable, the corresponding Input will not be displayed on the page at all.
It is also possible for some Fields to be writable, but yet not readable. This is counter-intuitive, but makes sense if you think about it: The quintessential example is that of a password. Generally, users are allowed to write their passwords, but these passwords are never allowed to be read.
In this case, an Input would be displayed and be active, but it will always be rendered without any contents. Contrast this with the normal case, where an Input would be rendered with the current Value of the Field pre-populated.
This is what it looks like in the code of Address:
def fields(self, fields):
fields.name = Field(label='Name', required=self.can_be_added(), writable=Action(self.can_be_added))
fields.email_address = EmailField(label='Email', required=True, writable=Action(self.can_be_edited))
Note how the name Field is required, but only when writable. Of course, to be able specify access rights, these no-arg methods needed to added to Address as well:
def can_be_added(self):
current_account = UserSession.for_current_session().account
return self.address_book.can_be_added_to_by(current_account)
def can_be_edited(self):
current_account = UserSession.for_current_session().account
return self.address_book.can_be_edited_by(current_account)
Any Input anywhere in our application which uses these Fields will now have the correct access control applied to it.
The same principle applies to the edit Event of an Address, and the Buttons created for it:
def fields(self, fields):
fields.name = Field(label='Name', required=self.can_be_added(), writable=Action(self.can_be_added))
fields.email_address = EmailField(label='Email', required=True, writable=Action(self.can_be_edited))
Easy, isn’t it?
Note
Although not shown in this example, any Widget can be also supplied with methods to determine its readability or writability by the currrent user. Widgets accept the read_check and write_check keyword arguments upon construction for this purpose. These *_check methods are just no-argument methods that will be called – not Actions as are the readable and writable keyword arguments of Fields.
The complete example¶
Below is the complete code for this example. A few methods needed to be added here and there in order to be able to specify the necessary rights:
from __future__ import unicode_literals
from __future__ import print_function
import elixir
from reahl.sqlalchemysupport import Session, metadata
from reahl.web.fw import UserInterface, UrlBoundView, CannotCreate
from reahl.web.ui import TwoColumnPage, Form, TextInput, LabelledBlockInput, Button, Panel, P, H, InputGroup, HMenu,\
PasswordInput, ErrorFeedbackMessage, VMenu, Slot, MenuItem, A, Widget, SelectInput, CheckboxInput
from reahl.systemaccountmodel import AccountManagementInterface, EmailAndPasswordSystemAccount, UserSession
from reahl.component.modelinterface import exposed, IntegerField, BooleanField, Field, EmailField, Event, Action, Choice, ChoiceField
class Address(elixir.Entity):
elixir.using_options(session=Session, metadata=metadata)
elixir.using_mapper_options(save_on_init=False)
address_book = elixir.ManyToOne('reahl.doc.examples.tutorial.access.access.AddressBook')
email_address = elixir.Field(elixir.UnicodeText)
name = elixir.Field(elixir.UnicodeText)
@classmethod
def by_id(cls, address_id, exception_to_raise):
addresses = Address.query.filter_by(id=address_id)
if addresses.count() != 1:
raise exception_to_raise
return addresses.one()
@exposed
def fields(self, fields):
fields.name = Field(label='Name', required=self.can_be_added(), writable=Action(self.can_be_added))
fields.email_address = EmailField(label='Email', required=True, writable=Action(self.can_be_edited))
@exposed('save', 'update', 'edit')
def events(self, events):
events.save = Event(label='Save', action=Action(self.save))
events.update = Event(label='Update')
events.edit = Event(label='Edit', writable=Action(self.can_be_edited))
def save(self):
Session.add(self)
def can_be_edited(self):
current_account = UserSession.for_current_session().account
return self.address_book.can_be_edited_by(current_account)
def can_be_added(self):
current_account = UserSession.for_current_session().account
return self.address_book.can_be_added_to_by(current_account)
class AddressBook(elixir.Entity):
elixir.using_options(session=Session, metadata=metadata)
owner = elixir.ManyToOne(EmailAndPasswordSystemAccount, required=True)
@classmethod
def by_id(cls, address_book_id, exception_to_raise):
address_books = AddressBook.query.filter_by(id=address_book_id)
if address_books.count() != 1:
raise exception_to_raise
return address_books.one()
@classmethod
def owned_by(cls, account):
return cls.query.filter_by(owner=account)
@classmethod
def address_books_visible_to(cls, account):
visible_books = cls.query.join(Collaborator).filter(Collaborator.account == account).all()
visible_books.extend(cls.owned_by(account))
return visible_books
@exposed
def fields(self, fields):
collaborators = [Choice(i.id, IntegerField(label=i.email)) for i in EmailAndPasswordSystemAccount.query.all()]
fields.chosen_collaborator = ChoiceField(collaborators, label='Choose collaborator')
fields.may_edit_address = BooleanField(label='May edit existing addresses')
fields.may_add_address = BooleanField(label='May add new addresses')
@exposed('add_collaborator')
def events(self, events):
events.add_collaborator = Event(label='Share', action=Action(self.add_collaborator))
def add_collaborator(self):
chosen_account = EmailAndPasswordSystemAccount.query.filter_by(id=self.chosen_collaborator).one()
self.allow(chosen_account, can_add_addresses=self.may_add_address, can_edit_addresses=self.may_edit_address)
# See https://groups.google.com/forum/?fromgroups=#!topic/sqlelixir/ZlR9Kvcor6Q
# addresses = elixir.OneToMany(Address)
@property
def addresses(self):
return Address.query.filter_by(address_book=self).all()
collaborators = elixir.OneToMany('reahl.doc.examples.tutorial.access.access.Collaborator', lazy='dynamic')
@property
def display_name(self):
return 'Address book of %s' % self.owner.email
def allow(self, account, can_add_addresses=False, can_edit_addresses=False):
Collaborator.query.filter_by(address_book=self, account=account).delete()
Collaborator(address_book=self, account=account,
can_add_addresses=can_add_addresses,
can_edit_addresses=can_edit_addresses)
def can_be_edited_by(self, account):
if account is self.owner:
return True
collaborator = self.get_collaborator(account)
return (collaborator and collaborator.can_edit_addresses) or self.can_be_added_to_by(account)
def can_be_added_to_by(self, account):
if account is self.owner:
return True
collaborator = self.get_collaborator(account)
return collaborator and collaborator.can_add_addresses
def can_be_added_to(self):
account = UserSession.for_current_session().account
return self.can_be_added_to_by(account)
def collaborators_can_be_added_by(self, account):
return self.owner is account
def collaborators_can_be_added(self):
account = UserSession.for_current_session().account
return self.collaborators_can_be_added_by(account)
def is_visible_to(self, account):
return self in self.address_books_visible_to(account)
def is_visible(self):
account = UserSession.for_current_session().account
return self.is_visible_to(account)
def get_collaborator(self, account):
collaborators = self.collaborators.filter_by(account=account)
count = collaborators.count()
if count == 1:
return collaborators.one()
if count > 1:
raise ProgrammerError('There can be only one Collaborator per account. Here is more than one.')
return None
class Collaborator(elixir.Entity):
elixir.using_options(session=Session, metadata=metadata)
account = elixir.ManyToOne(EmailAndPasswordSystemAccount)
can_add_addresses = elixir.Field(elixir.Boolean, default=False)
can_edit_addresses = elixir.Field(elixir.Boolean, default=False)
address_book = elixir.ManyToOne(AddressBook)
class AddressAppPage(TwoColumnPage):
def __init__(self, view, home_bookmark):
super(AddressAppPage, self).__init__(view, style='basic')
user_session = UserSession.for_current_session()
if user_session.is_logged_in():
logged_in_as = user_session.account.email
else:
logged_in_as = 'Not logged in'
self.header.add_child(P(view, text=logged_in_as))
self.header.add_child(HMenu.from_bookmarks(view, [home_bookmark]))
class LoginForm(Form):
def __init__(self, view, accounts):
super(LoginForm, self).__init__(view, 'login')
if self.exception:
self.add_child(ErrorFeedbackMessage(view, self.exception.as_user_message()))
self.add_child(LabelledBlockInput(TextInput(self, accounts.fields.email)))
self.add_child(LabelledBlockInput(PasswordInput(self, accounts.fields.password)))
self.define_event_handler(accounts.events.login_event)
self.add_child(Button(self, accounts.events.login_event))
class LogoutForm(Form):
def __init__(self, view, accounts):
super(LogoutForm, self).__init__(view, 'logout')
self.define_event_handler(accounts.events.log_out_event)
self.add_child(Button(self, accounts.events.log_out_event))
class HomePageWidget(Widget):
def __init__(self, view, address_book_ui):
super(HomePageWidget, self).__init__(view)
accounts = AccountManagementInterface.for_current_session()
user_session = UserSession.for_current_session()
if user_session.is_logged_in():
self.add_child(AddressBookList(view, address_book_ui))
self.add_child(LogoutForm(view, accounts))
else:
self.add_child(LoginForm(view, accounts))
class AddressBookList(Panel):
def __init__(self, view, address_book_ui):
super(AddressBookList, self).__init__(view)
current_account = UserSession.for_current_session().account
address_books = [book for book in AddressBook.address_books_visible_to(current_account)]
bookmarks = [address_book_ui.get_address_book_bookmark(address_book, description=address_book.display_name)
for address_book in address_books]
for bookmark in bookmarks:
p = self.add_child(P(view))
p.add_child(A.from_bookmark(view, bookmark))
class AddressBookPanel(Panel):
def __init__(self, view, address_book, address_book_ui):
self.address_book = address_book
super(AddressBookPanel, self).__init__(view)
self.add_child(H(view, 1, text='Addresses in %s' % address_book.display_name))
self.add_child(HMenu.from_bookmarks(view, self.menu_bookmarks(address_book_ui)))
self.add_children([AddressBox(view, address) for address in address_book.addresses])
def menu_bookmarks(self, address_book_ui):
return [address_book_ui.get_add_address_bookmark(self.address_book),
address_book_ui.get_add_collaborator_bookmark(self.address_book)]
class EditAddressForm(Form):
def __init__(self, view, address):
super(EditAddressForm, self).__init__(view, 'edit_form')
grouped_inputs = self.add_child(InputGroup(view, label_text='Edit address'))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, address.fields.name)))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, address.fields.email_address)))
grouped_inputs.add_child(Button(self, address.events.update.with_arguments(address_book_id=address.address_book.id)))
class AddAddressForm(Form):
def __init__(self, view, address_book):
super(AddAddressForm, self).__init__(view, 'add_form')
new_address = Address(address_book=address_book)
grouped_inputs = self.add_child(InputGroup(view, label_text='Add an address'))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, new_address.fields.name)))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, new_address.fields.email_address)))
grouped_inputs.add_child(Button(self, new_address.events.save.with_arguments(address_book_id=address_book.id)))
class AddressBox(Form):
def __init__(self, view, address):
form_name = 'address_%s' % address.id
super(AddressBox, self).__init__(view, form_name)
par = self.add_child(P(view, text='%s: %s ' % (address.name, address.email_address)))
par.add_child(Button(self, address.events.edit.with_arguments(address_id=address.id)))
class AddressBookView(UrlBoundView):
def assemble(self, address_book_id=None, address_book_ui=None):
address_book = AddressBook.by_id(address_book_id, CannotCreate())
self.title = address_book.display_name
self.set_slot('main', AddressBookPanel.factory(address_book, address_book_ui))
self.read_check = address_book.is_visible
class AddAddressView(UrlBoundView):
def assemble(self, address_book_id=None):
address_book = AddressBook.by_id(address_book_id, CannotCreate())
self.title = 'Add to %s' % address_book.display_name
self.set_slot('main', AddAddressForm.factory(address_book))
self.write_check = address_book.can_be_added_to
class AddCollaboratorForm(Form):
def __init__(self, view, address_book):
super(AddCollaboratorForm, self).__init__(view, 'add_collaborator_form')
grouped_inputs = self.add_child(InputGroup(view, label_text='Add a collaborator'))
grouped_inputs.add_child(LabelledBlockInput(SelectInput(self, address_book.fields.chosen_collaborator)))
rights_inputs = grouped_inputs.add_child(InputGroup(view, label_text='Rights'))
rights_inputs.add_child(LabelledBlockInput(CheckboxInput(self, address_book.fields.may_edit_address)))
rights_inputs.add_child(LabelledBlockInput(CheckboxInput(self, address_book.fields.may_add_address)))
grouped_inputs.add_child(Button(self, address_book.events.add_collaborator.with_arguments(address_book_id=address_book.id)))
class AddCollaboratorView(UrlBoundView):
def assemble(self, address_book_id=None):
address_book = AddressBook.by_id(address_book_id, CannotCreate())
self.title = 'Add collaborator to %s' % address_book.display_name
self.set_slot('main', AddCollaboratorForm.factory(address_book))
self.read_check = address_book.collaborators_can_be_added
class EditAddressView(UrlBoundView):
def assemble(self, address_id=None):
address = Address.by_id(address_id, CannotCreate())
self.title = 'Edit Address for %s' % address.name
self.set_slot('main', EditAddressForm.factory(address))
self.read_check = address.can_be_edited
class AddressBookUI(UserInterface):
def assemble(self):
home = self.define_view('/', title='Address books')
home.set_slot('main', HomePageWidget.factory(self))
self.address_book_page = self.define_view('/address_book', view_class=AddressBookView,
address_book_id=IntegerField(required=True),
address_book_ui=self)
self.add_address_page = self.define_view('/add_address', view_class=AddAddressView,
address_book_id=IntegerField(required=True))
edit_address_page = self.define_view('/edit_address', view_class=EditAddressView,
address_id=IntegerField(required=True))
self.add_collaborator_page = self.define_view('/add_collaborator', view_class=AddCollaboratorView,
address_book_id=IntegerField(required=True))
self.define_transition(Address.events.save, self.add_address_page, self.address_book_page)
self.define_transition(Address.events.edit, self.address_book_page, edit_address_page)
self.define_transition(Address.events.update, edit_address_page, self.address_book_page)
self.define_transition(AddressBook.events.add_collaborator, self.add_collaborator_page, self.address_book_page)
self.define_page(AddressAppPage, home.as_bookmark(self))
def get_address_book_bookmark(self, address_book, description=None):
return self.address_book_page.as_bookmark(self, description=description, address_book_id=address_book.id)
def get_add_address_bookmark(self, address_book, description='Add address'):
return self.add_address_page.as_bookmark(self, description=description, address_book_id=address_book.id)
def get_add_collaborator_bookmark(self, address_book, description='Add collaborator'):
return self.add_collaborator_page.as_bookmark(self, description=description, address_book_id=address_book.id)