= !BluePrint for Authorization = Alternative Proposal: [wiki:BluePrintAuthorizationB 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, 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 }}} === Caching === 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...) == 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}}} * 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 * not efficient now we have {{{session.s3.roles}}} * We need a function which efficiently handles OR, AND & NOT. With roles cached in session, this should be easy: {{{ def myfunction(): roles = session.s3.roles if not 1 in roles or (2 in roles and 3 in roles) and not 4 in roles: # redirect out ... }}} * 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 (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(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 = session.s3.roles if 1 in roles: # 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(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 = session.s3.roles 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: _fields = table.fields # We pull back the full record rather than just the fields needed to check permisisons, # 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 restriction in roles: 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 int(restriction) in roles: # 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. * {{{shn_role_check}}} covers this: [wiki:BluePrintAuthorization#Functionrestriction] * '''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