wiki:BluePrintAuthorization

Version 90 (modified by Fran Boon, 14 years ago) ( diff )

--

BluePrint for Authorization

Alternative Proposal: BluePrint for Authorization

Roles

  • Roles are stored in the auth_group.
  • These have no links to the groups in pr_group.
  • We are currently adopting a simplistic 3-tier approach of Person -> Role -> Permissions.
  • We consider that the 4-tier approach of Person -> Group -> Role -> Permissions is unnecessarily complex for users, despite giving strong flexibility & the potential for advanced admins to move persons into roles in bulk & including future members of the group.
  • Roles for the currently logged-in user are cached in the session for easy access throughout Model, Controllers & Views.
    • Replace auth.has_membership(group) in default modules menu in 01_modules.py

In models/00_utils.py:

def shn_sessions():
    ...
    roles = []
    try:
        user_id = auth.user.id
        _memberships = db.auth_membership
        memberships = db(_memberships.user_id == user_id).select(_memberships.group_id)
        for membership in memberships:
            roles.append(membership.group_id)
    except:
        # User not authenticated therefore has no roles other than '0'
        pass
    session.s3.roles = roles

Caching

If we implmented caching of the DB lookup for memberships, then this would need to be invalidated as part of the onaccept for auth_group.update()

  • This could be done by storing a key in the session & changing the key (Dominic to expand...)
  • It is not currently proposed.

Functions added to AuthS3 class

All authentication functions should be stored in the AuthS3 class in modules/sahana.py.

shn_accessible_query() & shn_has_permission() should be moved from 00_utils.py to here.

New function:

def shn_has_role(self, role):
    """
    Check whether the currently logged-in user has a role
    @param role can be integer or a name
    """
    
    if type(role) != int:
        role = deployment_settings.auth.roles[role]

    if role in session.s3.roles:
        return True
    else:
        return False

Restrictions

Module restriction

  • s3.modules in 01_modules.py
  • Add Controller check as well as menu check.
    • appadmin.py & dvi.py have these already
      • need modifying to use session.s3.roles
      • need modifying to use auth.shn_has_role()
  • Configure permissions in 000_config.py instead of 01_modules.py
    • Change deployment_settings.modules from a list of strings to a Storage()
      • Custom methods can be added in here & used in exactly the same way as the standard 'create', 'read', 'update' & 'delete'.
        # Need to define Roles in dict for visibility by modules before inserted into DB
        deployment_settings.auth.roles = {
            1 : "Administrator",
            2 : "Authenticated",
            ....
            6 : "AdvancedJS",
        }
        
        deployment_settings_modules = Storage(
            gis = Storage(
                name_nice = "Mapping",
                description = "Situation Awareness & Geospatial Analysis",
                readable = None,    # All Users (inc Anonymous) can see this module in the default menu & access the controller
                #writable = None,    # All Authenticated users can edit resources which aren't specially protected
                module_type = 2,    # Used to locate the module in the default menu
                resources = Storage(
                    apikey = {
                            read : "|1|",     # This resource is only visible to Administrators
                        },
                    layer_js = {
                            create : "|%d|" % deployment_settings.auth.roles["AdvancedJS"],    # This resource requires the 'AdvancedJS' role to create (or admin)
                            delete : "|%d|" % deployment_settings.auth.roles["AdvancedJS"],    # This resource requires the 'AdvancedJS' role to delete (or admin)
                            update : "|%d|" % deployment_settings.auth.roles["AdvancedJS"],    # This resource requires the 'AdvancedJS' role to update (or admin)
                        }
                ),
            ),
            ...
        )
        

Function restriction

  • Decorator function:
    • @auth.requires_membership("Administrator")
      • not efficient now we have session.s3.roles - should write a @auth.shn_requires_membership() decorator for the simple case
      • doesn't support OR, doesn't support NOT
  • We need a function which efficiently handles OR, AND & NOT. With roles cached in session, this is easy:
    def myfunction():
        shn_has_role = auth.shn_has_role
        if not shn_has_role(1) or (shn_has_role("BadRole1") and shn_has_role("BadRole2")) and not shn_has_role("GoodRole"):
            # redirect out
        ...
    
  • We can have a 'sudo' mechanism where users (maybe restricted to a certain role) get temporary access to other roles' rights (e.g. admin) within the Controller function's confines:
    def myfunction:
        " This function (or section within a function) requires us to temporarily give admin rights to the user "
        ...
        session.s3.roles.append(1)
        ...
    
  • Developer can put in any additional manual restrictions or alternate routes, as-required

Resource restriction

  • Bad: REST controller can be blocked via a Decorator
    • we'd need to patch other functions, such as sync, which would be hard to maintain
  • Bad: Full security policy (native web2py) can be invoked, but this is painful (based on protected by default & granted manually) & untested within S3 recently
  • Need a new method: open by default & restricted manually (see below for proposed implementation)
    • Option1: Use an auth_permission table similar to Web2Py 'full' but just for tables & with group_id as multiple=True
    • Option2: Set within 000_config.py, along with module permissions (see above example)
      • Means less DAL calls
    • Option3: Have an onvalidation which auto-populates the reader_id/writer_id fields in records
      • Means that no additional auth check at table-level needed
      • Means that the security model isn't held all in one place, so not as easy to maintain?
      • If done at validation time then it means that no extra DAL calls are done when creating resources (just extra field(s) within the 1 DAL call)
    • Once new solution in-place:
      • remove security_policy from s3_setting table & session (00_utils.py)
      • modify shn_action_buttons() in 00_utils.py
      • Check Views/Controllers which hide admin_id when simple security policy active

Record restriction

  • Add 2 reusable multiple=True fields to each table which needs this: reader_id & writer_id combined as permissions_id for easy reuse within table definitions
    • Full backward compatibility since they default to None
  • For List views modify shn_accessible_query() (called from shn_list() & shn_search())
    • combine with the deleted==True check
      • makes it easier to then replace that check with an 'inactive' field which is a date instead of a boolean, so that records can be set to expire (as well as giving us easy access to know when a record was deleted)
    • Preferred Option: Do the check alongside deleted as part of a big JOIN
      def shn_accessible_query(self, table):
          """
          Return a filter of those records which are readable by the currently logged-in user
          - deleted records are filtered
          - records to which they don't have permissions are filtered
          """
      
          if "deleted" in table.fields:
              deleted = (table.deleted == None)
          else:
              deleted = 1
      
          roles = self.session.s3.roles
      
          if self.shn_has_role(1):
              # Admins see all data
              query = deleted
          elif "reader_id" not in table.fields:
              # No record-level permissions
              query = deleted
          else:
             # Fields with no restriction
             accessible = (table.reader_id == None)
             for role in roles:
                 accessible = accessible & (table.reader_id.like('%|%d|%' % role))
             query = deleted & accessible
          return query
      
      def user_function():
          ...
          table = db[tablename]
          available = shn_accessible_query(table)
          query = available & query
          ...
      
      • Advantages:
        • Combines the deleted into single API call
        • Single JOIN for optimal DB performance (Assumption needs testing)
    • Alternate Option: Do the check in Python after the initial query has returned
      • Advantage: Might have better performance than complex DB string?
      • Disadvantage: More records pulled from DB than necessary
  • For single records modify shn_has_permission() (called from shn_create(), shn_delete(), shn_read() & shn_update(), but also available for other functions)
    • Where possible, we merge this with the reading of the record to have a single DAL hit instead of 1 for the permissions check & another for the actual read by modifying the calling functions appropriately:
      • modules/sahana.py defines a new SQLFORM2() class which takes a record as an option to a record_id
        • if type(record) == gluon.sql.Row:
      • CrudS3 modifies Crud.create() to take a record as an option to a record_id & calls SQLFORM2()
      • CrudS3 modifies Crud.update() to take a record as an option to a record_id & calls SQLFORM2()
        def shn_has_permission(self, name, tablename, record_id = 0):
            """
                S3 framework function to define whether a user can access a record in manner "name"
                - if checking a table (record_id = 0), then it returns Boolean
                - if checking a record (record_id > 0), then it returns the record or None
            """
        
            roles = self.session.s3.roles
        
            table = db[tablename]
            # Check if table is restricted (Options 1 & 2)
            if shn_has_role(1):
                # Admins see all tables
                authorised = True
            else:   
                # Option 1
                #restriction = db(db.auth_permission.table_name == tablename).select(db.auth_permission.group_id, limitby=(0, 1), cache=(cache.ram, 60)).first().group_id
                #if restriction:
                #   authorised = False
                #   restrictions = re.split("\|", restriction)[1:-1]
                    # Assume we generally have fewer restrictions than roles
                #   for restriction in restrictions:
                #        if restriction == "0" or self.shn_has_role(restriction):
                             # restriction 0 is anonymous
                #            authorised = True
                #else:
                #    authorised = True 
        
                # Option 2
                module, resource = tablename.split("_", 1)
                try:
                    restriction = deployment_settings_modules["module"].resources["resource"]["name"]
                except:
                    restriction = None
                
                if restriction:
                    authorised = False
                    restrictions = re.split("\|", restriction)[1:-1]
                    # Assume we generally have fewer restrictions than roles
                    for restriction in restrictions:
                        if restriction == "0" or self.shn_has_role(restriction):
                            # restriction 0 is anonymous
                            authorised = True
                        
                else:
                    # No restriction
                    authorised = True
        
            # Options 3:
            #if record_id:
            # Options 1 & 2:
            if record_id and authorised:
        
                _fields = table.fields
        
                # We pull back the full record rather than just the fields needed to check permissions,
                # so that the caller doesn't need to make a 2nd DAL call to access the data
                record = db(table.id == record_id).select(limitby=(0, 1)).first()
        
                if "deleted" in  _fields:
                    # Check if record is deleted
                    if record.deleted:
                        record = None
                        return record
        
                if 1 in roles:
                    # Admin is always authorised to view undeleted data (deleted data accessible through alternate UI)
                    return record
        
                if not "reader_id" in _fields:
                    # No record-level permissions (we assume that reader_id & writer_id fields always present/absent together via use of 'permissions_id')
                    return record
        
                # Check the record's auth fields
                if name == "read":
                    if not table.reader_id:
                        return record
                    else:
                        authorised = False
                        restrictions = re.split("\|", table.reader_id)[1:-1]
                        # Assume we generally have fewer restrictions than roles
                        for restriction in restrictions:
                            if self.shn_has_role(restriction):
                                authorised = True
                            elif restriction == "2" and "created_by" in _fields:
                                # 'Creator' restriction
                                if auth.user.id == table.created_by:
                                    authorised = True
                        if not authorised: 
                            record = None
                        return record
                        
                elif name in ["delete", "update"]:
                    if not table.writer_id:
                        return record
                    else:
                        authorised = False
                        restrictions = re.split("\|", table.writer_id)[1:-1]
                        # Assume we generally have fewer restrictions than roles
                        for restriction in restrictions:
                            if restriction == "0" or self.shn_has_role(restriction):
                                # restriction 0 is anonymous
                                authorised = True
                            elif restriction == "2" and "created_by" in _fields:
                                # 'Creator' restriction
                                if auth.user.id == table.created_by:
                                    authorised = True
                        if not authorised: 
                            record = None
                        return record
        
                else:
                    # Something went wrong
                    session.error = str(T("Invalid mode sent to")) + " shn_has_permission(): " + name
                    redirect(URL(r=request, f="index"))
        
        if record_id:
            return None
        else:
            return authorised
        
  • UI to manage the fields.
    • We expect relatively few groups per instance, so can use the checkboxes widget?
      • can filter out some groups?
    • Have a single checkbox for 'Restrict access' which then opens out the 2 fields.

This reads a record even if you don't need it (e.g. if you have found the record by another query, e.g. a join, this would unconditionally load the record again), and it reads the record in full even if you don't need the whole record, also you cannot distinguish between "not permitted" and "record not found", it retrieves a record from the database even if deleted, and then drops the record, and for multi-record checks it compels you to retrieve record by record instead of retrieving a set of records and check them afterwards. Also, this model makes non-hardcoded roles extremly inefficient.

Field restriction

  • In model (for access by all controllers, such as sync):
    if 1 not in roles and deployment_settings.auth.roles["MyRole"] not in roles:
        table.field1.readable = False
        table.field2.writable = False
    

Location restriction

e.g. Country or Region

  • Add a special role 'Geographic' which can be added to writer_id (& maybe reader_id although less use case for this)
    • Patch shn_has_permission() (& maybe shn_accessible_query()) to spot this special case &, if no other roles match, then do a lookup in another table (or deployment_settings dict)
  • NB If doing this through a table, then need to ensure that this table is protected appropriately

Organisation restriction

  • This could be all members of the Organisation or just the 'Focal Point'
  • Add a special role 'Organisation' which can be added to writer_id (& maybe reader_id although less use case for this)
    • Patch shn_has_permission() (& maybe shn_accessible_query()) to spot this special case &, if no other roles match, then do a lookup in another table (or deployment_settings dict)
  • NB If doing this through a table (such as a person's organisation), then need to ensure that this table is protected appropriately

(nursix) This could be three, all at the same time, the FRP case: "all members of the organisation" have certain permissions AND "some members of the same org" have different permissions AND "some members of another org" have again other permissions, and all others have yet other permissions, and finally we do not have something like "members of organisations" at all.

Author restriction

Only allow a user to update records which they created.

  • Add a special role 'Creator' which can be added to writer_id (& maybe reader_id although less use case for this)
    • Patch shn_has_permission() (& maybe shn_accessible_query()) to spot this special case &, if no other roles match, then do a check between auth.user.id & table.created_by

Anonymous authoring

Some tables should be writable by unauthenticated users

  • Need to differentiate the 2 (can deposit new but not edit existing)
  • Function shn_has_permission() above handles this by the admin setting the relevant permission to |0|
  • Might want to be have new records by unauthenticated users not be visible in lists until an admin has approved them
    • Add a new field 'approved', which default to 'False' for unauthenticated users
    • Filter for this: query = query & (table.approved == True)
      • Q: Do we keep this in individual controllers
        • manual & need to check additional functions like Sync
      • Q: Do we do check globally within shn_accessible_query() & shn_has_permission
        • means extra logic every CRUD check when most resources don't need this

Visibility of Options

Ideally options which a user doesn't have permission for should be hidden from them.

  • Modules are hidden from Modules menu & front page
  • Action buttons in tables only show 'Details' for unauthenticated users, but 'Update'/'Delete' for authenticated ones
    • ideally would distinguish per record if some records restricted (We already pull the data for the rows, so need to ensure SQL query includes relevant fields & that we act upon them)
  • Controller menus should be adjusted (currently done manually => harder maintenance)
  • Views should be adjusted (currently done manually => harder maintenance)

Specific Examples

  • A Person's Contacts shouldn't be visible by default.
    • Authenticated is OK
      • Simply add the Authenticated group (2) to the table (or records in the table if using Option 3)
      • This requires all authenticated users to be added to the 'Authenticated' group
  • A Person's Subscriptions shouldn't be visible by default.
    • Admin or themselves is OK
      • Option A: restore the web2py default of adding 1 group per user!
        • In models/00_settings.py: auth.settings.create_user_groups = True
        • Check using auth.user_group(auth.user.id)
        • Filter these out of our views?
      • Option B: use the link from subscription to person & do manual check somewhere
        • tbc
    • We may want to allow people to be subscribed to things by others.
      • This would need a 'Subscriber Admin' role
        • We could give this role to all registered users by default if we Hook into the registration onaccept
          • Currently this requires modifying shn_register() in modules/sahana.py
          • Replace with a registration_onvalidation and a registration_onaccept hook, which operate with secured copies of the registration data (e.g. no password contained).
  • Messaging: If access to a record is restricted then access to Messages relating to that record should also be restricted
    • unless routed somewhere visible as well!
    • block subscription
    • onvalidation on message routing (i.e. tagging) to check if the only tags are on restricted resources...if they are then restrict the message too.
      • onvalidation not onaccept so that only 1 DAL update() is done
  • FRP: a request that has been submitted by an IP user can be completely changed as well as cancelled/deleted by IP users belonging to the same organisation, but only until a WFP user has confirmed the request - after that the IP users can only change certain fields in the record, and not delete the record anymore.
    • Field in requests table 'confirmed' which is writable=False unless role==WFP
    • onvalidation for requests does a check whether confirmed is set &, if it is, only allows the relevant fields to be changed & denies deletion
  • FRP: if a user has a role which would normally be granted permission to a resource has another role, then deny them access instead.
  • FRP:
    • An 'IP User' is permitted to submit requests on behalf of his organisation.
      1. Hide 'Create Request' menu item unless this role present
      2. Restrict the 'create' right on the Requests table to members of this role
      3. In View request_create provide jQuery which hides the or_organisation field for people with this role.
      4. Match this server-side by providing a create_onvalidation hook which says that if someone in this role then set the Organisation to theirs.
    • An 'IP Admin' is an 'IP User' who can, in addition, add new 'IP Users' to their organisation.
      1. just has the IP_Admin role added to menu unhiding
      2. just has the IP_Admin role added to restriction
      3. just has the IP_Admin role added to jQuery hide
      4. just has the IP_Admin role added to create_onvalidation
      5. Hide 'Add IP User' menu item unless this role present
        • this takes you to a specialised auth_membership form with the IP_User role preselected (can be done in jQuery hiding the selected real dropdown & putting text up in visible part instead)
      6. Restrict the 'create' right on the auth_membership table to members of this role
      7. Provide onvalidation on auth_membership to set the group_id to 'IP_User' if this role is set
    • A 'FAC User' is an 'IP Admin' for all organisations, but without 'IP User' permissions except for their own organisation (=WFP).
      1. just has the FAC_User role added to menu unhiding
      2. just has the FAC_User role added to restriction
      3. just has the FAC_User role added to jQuery hide
      4. just has the FAC_User role added to create_onvalidation
      5. takes to a normal form without the jQuery hiding (can be identical form just the jQuery doesn't activate for this role)
      6. just has the FAC_User role added to restriction
      7. just has the FAC_User role added to onvalidation
    • A 'FAC Admin' is a 'FAC User' with additional 'IP User' permissions for all organisations.
      1. just has the FAC_Admin role added to menu unhiding
      2. just has the FAC_Admin role added to restriction
      3. has nothing special (jQuery doesn't activate for this role)
      4. has nothing special (no special onvalidation)
      5. takes to a normal form without the jQuery hiding (can be identical form just the jQuery doesn't activate for this role)
      6. just has the FAC_Admin role added to restriction
      7. just has the FAC_Admin role added to onvalidation

BluePrintAuthenticationAccess

BluePrints

Note: See TracWiki for help on using the wiki.