[[TOC]] = !BluePrint for Authorization = Immediate need is for [PakistanAuthorisation Pakistan] [TaiwanAuthorisation Taiwan] needs 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, limitby=(0, 1)) 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", access = None, # All Users (inc Anonymous) can see this module in the default menu & access the controller 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.shn_requires_membership("Administrator")~~ * ~~rewritten to use shn_has_role()~~ * 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 === ==== Option A ==== * 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. ==== Option B (Preferred?) ==== Use the separate auth_permission table. Disadvantages: * All Read permissions checks need to read 2 tables (hopefully via a Join rather than 2 DB calls) Advantages: * More flexible since other methods can be added easily as-required (e.g. delete) * Module authors don't need to be aware of these restrictions (unless making use of them in other workflow areas) * 1 secure place to manage all record-level restrictions * No DB migrations/Code changes needed to implement restrictions === Field restriction === * In model (for access by all controllers, such as sync): {{{ if not shn_has_role("MyRole"): 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}}} or {{auth_permission}}. * 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 special roles 'Organisation' & 'Focal Point' which can be added to {{{writer_id}}} or {{auth_permission}}. * Patch {{{shn_has_permission()}}} (& maybe {{{shn_accessible_query()}}}) to spot these special cases &, 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. * Organisations might need to be able to set privacy options themselves about what data of theirs they wish public other orgs to be able to see * Have an organisation/privacy 1-1 component which sets these. This provides record-level restrictions for either the 'Authenticated' role or specific Organisations. Setting these fields affects all existing records & also hooks into the onaccept for the various component resources to ensure new records also have these settings set properly. === Author restriction === Only allow a user to update records which they created. * Add a special role 'Creator' which can be added to {{{writer_id}}} or {{auth_permission}}. * 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. * If being deployed by a small organisation & only 'staff' have logins then: * 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~~ * If being deployed by a large organisation &/or 'public' have logins then: * Members of X Role is OK * Simply add the X group to the table (or records in the table if using Option 3) * If being deployed as a shared instance with multiple organisations then: * Members of X Role in Y Organisation is OK * 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 Alternate Designs: * http://www.codeproject.com/KB/database/AFCAS.aspx ---- BluePrintAuthenticationAccess BluePrints