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 = LoginSession.for_current_session().account
        return self.address_book.can_be_added_to_by(current_account)
    def can_be_edited(self):
        current_account = LoginSession.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 print_function, unicode_literals, absolute_import, division


from sqlalchemy import Column, ForeignKey, Integer, UnicodeText, Boolean
from sqlalchemy.orm import relationship

from reahl.sqlalchemysupport import Session, Base

from reahl.web.fw import UserInterface, UrlBoundView, CannotCreate
from reahl.web.ui import HTML5Page, Form, TextInput, LabelledBlockInput, Button, Div, P, H, FieldSet, Menu, \
                         HorizontalLayout,\
                         PasswordInput, ErrorFeedbackMessage, Slot, MenuItem, A, Widget, SelectInput, CheckboxInput
from reahl.domain.systemaccountmodel import AccountManagementInterface, EmailAndPasswordSystemAccount, LoginSession
from reahl.component.exceptions import ProgrammerError
from reahl.component.modelinterface import exposed, IntegerField, BooleanField, Field, EmailField, Event, Action, Choice, ChoiceField
from reahl.web.layout import PageLayout
from reahl.web.pure import ColumnLayout, UnitSize


class Address(Base):
    __tablename__ = 'access_address'

    id              = Column(Integer, primary_key=True)    
    address_book_id = Column(Integer, ForeignKey('access_address_book.id'))
    address_book    = relationship('reahl.doc.examples.tutorial.access.access.AddressBook')
    email_address   = Column(UnicodeText)
    name            = Column(UnicodeText)

    @classmethod
    def by_id(cls, address_id, exception_to_raise):
        addresses = Session.query(cls).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 = LoginSession.for_current_session().account
        return self.address_book.can_be_edited_by(current_account)

    def can_be_added(self):
        current_account = LoginSession.for_current_session().account
        return self.address_book.can_be_added_to_by(current_account)


class AddressBook(Base):
    __tablename__ = 'access_address_book'

    id              = Column(Integer, primary_key=True)

    owner_id   = Column(Integer, ForeignKey(EmailAndPasswordSystemAccount.id), nullable=False)
    owner      = relationship(EmailAndPasswordSystemAccount)
    collaborators = relationship('reahl.doc.examples.tutorial.access.access.Collaborator', lazy='dynamic',
                                 backref='address_book')

    @classmethod
    def by_id(cls, address_book_id, exception_to_raise):
        address_books = Session.query(cls).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 Session.query(cls).filter_by(owner=account)
        
    @classmethod
    def address_books_visible_to(cls, account):
        visible_books = Session.query(cls).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 Session.query(EmailAndPasswordSystemAccount).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 = Session.query(EmailAndPasswordSystemAccount).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)

    @property
    def addresses(self):
        return Session.query(Address).filter_by(address_book=self).all()

    @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):
        Session.query(Collaborator).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 = LoginSession.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 = LoginSession.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 = LoginSession.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(Base):
    __tablename__ = 'access_collaborator'
    id      = Column(Integer, primary_key=True)

    address_book_id = Column(Integer, ForeignKey(AddressBook.id))

    account_id = Column(Integer, ForeignKey(EmailAndPasswordSystemAccount.id), nullable=False)
    account = relationship(EmailAndPasswordSystemAccount)
    
    can_add_addresses = Column(Boolean, default=False)
    can_edit_addresses = Column(Boolean, default=False)


class AddressAppPage(HTML5Page):
    def __init__(self, view, home_bookmark):
        super(AddressAppPage, self).__init__(view, style='basic')
        self.use_layout(PageLayout())
        contents_layout = ColumnLayout(('main', UnitSize('1/2'))).with_slots()
        self.layout.contents.use_layout(contents_layout)

        login_session = LoginSession.for_current_session()
        if login_session.is_logged_in():
            logged_in_as = login_session.account.email
        else:
            logged_in_as = 'Not logged in'

        self.layout.header.add_child(P(view, text=logged_in_as))
        self.layout.header.add_child(Menu(view).use_layout(HorizontalLayout()).with_bookmarks([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()
        login_session = LoginSession.for_current_session()
        if login_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(Div):
    def __init__(self, view, address_book_ui):
        super(AddressBookList, self).__init__(view)

        current_account = LoginSession.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(Div):
    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(Menu(view).use_layout(HorizontalLayout()).with_bookmarks(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(FieldSet(view, legend_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(FieldSet(view, legend_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(FieldSet(view, legend_text='Add a collaborator'))
        grouped_inputs.add_child(LabelledBlockInput(SelectInput(self, address_book.fields.chosen_collaborator)))

        rights_inputs = grouped_inputs.add_child(FieldSet(view, legend_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)