wiki:BluePrintAuthorization

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

Store roles in session

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. 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, cache=(cache.ram, 60)) # 60s cache
        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

Restrictions

Module restriction

  • s3.modules in 01_modules.py
  • Add Controller check as well as menu check.
    • appadmin.py & dvi.py have these already
  • Configure permissions in 000_config.py instead of 01_modules.py
    • Change deployment_settings.modules from a list of strings to a Storage()
      # 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")
    • doesn't support OR, doesn't support NOT
  • We need to write a function which handles OR & NOT: shn_role_check(1, 2, not=(3))
  • Developer can put in 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
    • 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(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
      
          try:
              user_id = auth.user.id
              _memberships = db.auth_membership
              memberships = db(_memberships.user_id == user_id).select(_memberships.group_id, cache=(cache.ram, 60))
          except:
              memberships = None
          
          roles = []
          for membership in memberships:
              roles.append(membership.group_id)
      
          if 1 in roles:
              # Admins see all data
              query = deleted
          else:
             # Fields with no restriction
             accessible = (table.reader_id == None)
             for role in roles:
                 #accessible = accessible & (table.reader_id == str(role)) & (table.reader_id.like('%d|%' % role)) & (table.reader_id.like('%|%d|%' % role)) & (table.reader_id.like('%|%d' % role))
                 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)
    def shn_has_permission(name, tablename, record_id = 0):
        """
            S3 framework function to define whether a user can access a record in manner "name"
        """
    
        try:
            user_id = auth.user.id
            _memberships = db.auth_membership
            memberships = db(_memberships.user_id == user_id).select(_memberships.group_id, cache=(cache.ram, 60))
        except:
            memberships = None
        
        roles = []
        for membership in memberships:
            roles.append(membership.group_id)
    
        table = db[tablename]
        # Check if table is restricted (Options 1 & 2)
        if 1 in roles:
            # 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 int(restriction) in roles:
                         # 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 int(restriction) in roles:
                        # restriction 0 is anonymous
                        authorised = True
                    
            else:
                # No restriction
                authorised = True
    
        # Options 3:
        #if record_id:
        # Options 1 & 2:
        if record_id and authorised:
    
            record = None
    
            if "deleted" in table.fields:
                # Check if record is deleted
                record = db(table.id == record_id).select(table.deleted, table.reader_id, table.writer_id, limitby=(0, 1)).first()
                if record.deleted:
                    authorised = False
                    return authorised
    
            elif 1 in roles:
                # Admin is always authorised to view undeleted data (deleted data accessible through alternate UI)
                authorised = True
                return authorised
    
            # Check the record's auth fields
            if not record:
                record = db(table.id == record_id).select(table.reader_id, table.writer_id, limitby=(0, 1)).first()
            if name == "read":
                if not table.reader_id:
                    authorised = True
                else:
                    authorised = False
                    restrictions = re.split("\|", table.reader_id)[1:-1]
                    # Assume we generally have fewer restrictions than roles
                    for restriction in restrictions:
                        if restriction in roles:
                            authorised = True
                    
            elif name in ["delete", "update"]:
                if not table.writer_id:
                    authorised = True
                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 int(restriction) in roles:
                            # restriction 0 is anonymous
                            authorised = True
    
            else:
                # Something went wrong
                session.error = str(T("Invalid mode sent to")) + " shn_has_permission(): " + name
                redirect(URL(r=request, f="index"))
    
    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.

Field restriction

  • In model (for access by all controllers, such as sync):
    try:
        user_id = auth.user.id
        _memberships = db.auth_membership
        memberships = db(_memberships.user_id == user_id).select(_memberships.group_id, cache=(cache.ram, 60))
    except:
        memberships = None
    
    roles = []
    for membership in memberships:
        roles.append(membership.group_id)
    
    if 1 not in roles and deployment_settings.auth.roles["MyRole"] not in roles:
        table.field.readable = False
    
    • NB If doing this then the roles checks inside shn_has_permission() & shn_accessible_fields() should be modified to read this global value instead of more DAL queries (even cached)!

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

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

Caching

Permissions are cached to reduce overheads.

  • Cache should be invalidated as part of the onaccept for auth_group.update()
    • This can be done by storing a key in the session & changing the key (Dominic to expand...)

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.
    • shn_role_check covers this: BluePrintAuthorization
    • Option: Introduce full-blown model of Persons -> Groups (pr_group) -> Roles (auth_group) -> Permissions
      • Downside: Extra layer of confusion for both Admins & Developers
      • Upside: Very flexible for Admins to manage which users (in bulk & future members) get what access to resources (subject to sufficient roles being made available by the developer)

BluePrintAuthenticationAccess

BluePrints

Note: See TracWiki for help on using the wiki.