Table of Contents
BluePrint for Authorization
Immediate need is for Pakistan
Taiwan needs
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.
Replaceauth.has_membership(group)
in default modules menu in01_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 in01_modules.py
- Add Controller check as well as menu check.
appadmin.py
&dvi.py
have these alreadyneed modifying to usesession.s3.roles
need modifying to useauth.shn_has_role()
- Configure permissions in
000_config.py
instead of01_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) } ), ), ... )
- Custom methods can be added in here & used in exactly the same way as the standard 'create', 'read', 'update' & 'delete'.
- Change
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 & withgroup_id
asmultiple=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()
in00_utils.py
Check Views/Controllers which hide admin_id when simple security policy active
- remove security_policy from
- Option1: Use an
Record restriction
Option A
- Add 2 reusable
multiple=True
fields to each table which needs this:reader_id
&writer_id
combined aspermissions_id
for easy reuse within table definitions- Full backward compatibility since they default to None
- For List views modify
shn_accessible_query()
(called fromshn_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)
- Advantages:
- 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
- combine with the
- For single records modify
shn_has_permission()
(called fromshn_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 newSQLFORM2()
class which takes a record as an option to a record_idif type(record) == gluon.sql.Row:
CrudS3
modifiesCrud.create()
to take a record as an option to a record_id & callsSQLFORM2()
CrudS3
modifiesCrud.update()
to take a record as an option to a record_id & callsSQLFORM2()
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(f="index")) if record_id: return None else: return authorised
- 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:
- 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.
- We expect relatively few groups per instance, so can use the checkboxes widget?
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()
(& maybeshn_accessible_query()
) to spot this special case &, if no other roles match, then do a lookup in another table (or deployment_settings dict)
- Patch
- 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()
(& maybeshn_accessible_query()
) to spot these special cases &, if no other roles match, then do a lookup in another table (or deployment_settings dict)
- Patch
- 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()
(& maybeshn_accessible_query()
) to spot this special case &, if no other roles match, then do a check betweenauth.user.id
&table.created_by
- Patch
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
- Q: Do we keep this in individual controllers
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 pageAction 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
- Authenticated is OK
- 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)
- Members of X Role is OK
- If being deployed as a shared instance with multiple organisations then:
- Members of X Role in Y Organisation is OK
- If being deployed by a small organisation & only 'staff' have logins then:
- 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?
- In
- Option B: use the link from subscription to person & do manual check somewhere
- tbc
- Option A: restore the web2py default of adding 1 group per user!
- 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()
inmodules/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).
- Currently this requires modifying
- We could give this role to all registered users by default if we Hook into the registration onaccept
- This would need a 'Subscriber Admin' role
- Admin or themselves is OK
- 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
- FRP:
- An 'IP User' is permitted to submit requests on behalf of his organisation.
- Hide 'Create Request' menu item unless this role present
- Restrict the 'create' right on the Requests table to members of this role
- In View request_create provide jQuery which hides the
or_organisation field
for people with this role. - 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.
- just has the IP_Admin role added to menu unhiding
- just has the IP_Admin role added to restriction
- just has the IP_Admin role added to jQuery hide
- just has the IP_Admin role added to create_onvalidation
- 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)
- Restrict the 'create' right on the
auth_membership
table to members of this role - 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).
- just has the FAC_User role added to menu unhiding
- just has the FAC_User role added to restriction
- just has the FAC_User role added to jQuery hide
- just has the FAC_User role added to create_onvalidation
- takes to a normal form without the jQuery hiding (can be identical form just the jQuery doesn't activate for this role)
- just has the FAC_User role added to restriction
- just has the FAC_User role added to onvalidation
- A 'FAC Admin' is a 'FAC User' with additional 'IP User' permissions for all organisations.
- just has the FAC_Admin role added to menu unhiding
- just has the FAC_Admin role added to restriction
- has nothing special (jQuery doesn't activate for this role)
- has nothing special (no special onvalidation)
- takes to a normal form without the jQuery hiding (can be identical form just the jQuery doesn't activate for this role)
- just has the FAC_Admin role added to restriction
- just has the FAC_Admin role added to onvalidation
- An 'IP User' is permitted to submit requests on behalf of his organisation.
Alternate Designs: