Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add location-based row level security to group and individual #121

Closed
wants to merge 21 commits into from

Conversation

weilu
Copy link
Contributor

@weilu weilu commented Sep 6, 2024

Below is a full list of GQL queries and mutations. @sniedzielski do I need to add row security to all of them or are there ones not used by the frontend or not relevant in this context?

  • resolve_individual
  • resolve_individual_enrollment_summary
  • resolve_individual_history
  • resolve_individual_data_source
  • resolve_group_data_source
  • resolve_individual_data_source_upload
  • resolve_group
  • resolve_group_history
  • resolve_group_individual
  • resolve_group_individual_history
  • resolve_individual_data_upload_history
  • resolve_group_enrollment_summary
  • resolve_global_schema
  • create_individual
  • update_individual
  • delete_individual
  • undo_delete_individual
  • create_group
  • update_group
  • delete_group
  • add_individual_to_group
  • edit_individual_in_group
  • remove_individual_from_group
  • create_group_individuals => doesn't seem to be used anywhere, remove?
  • create_group_and_move_individual => doesn't seem to be used anywhere, remove?
  • confirm_individual_enrollment
  • confirm_group_enrollment
  • import_individuals ---- views.py
  • download_template_file
  • download_invalid_items
  • download_individual_upload
  • individualExport ---- ExportableQueryMixin
  • groupExport
  • groupIndividualExport

@weilu weilu marked this pull request as draft September 6, 2024 21:22
@sniedzielski
Copy link
Contributor

I wonder about adding this row security in 'group' like:
"add_individual_to_group +
edit_individual_in_group + create_group_and_move_individual" to ensure we can add the individual only related to user's specific location. @delcroip What do you think about it and the whole changes? Should we include it in upcoming release?

@weilu weilu marked this pull request as ready for review October 1, 2024 14:33
@weilu
Copy link
Contributor Author

weilu commented Oct 1, 2024

@sniedzielski I'm marking it as ready for review as this PR is already a bit too big. It covers row-level security changes for the models and corresponding CRUD operations. Kindly review please.

I'll make separate PRs for the rest of the changes, tentatively one for csv import/export and another for enrollment.

@delcroip delcroip self-requested a review October 3, 2024 10:00
Copy link
Member

@delcroip delcroip left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some comments, please check them and give me you thoughts

@@ -13,12 +15,50 @@ class Individual(HistoryModel):
#TODO WHY the HistoryModel json_ext was not enough
json_ext = models.JSONField(db_column="Json_ext", blank=True, default=dict)

village = models.ForeignKey(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should not have location instead, related names should be 'individuals'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting point. I thought about this long and hard – the pro is the flexibility, but the con is also the flexibility. Say we go with location and allow it to be defined at any of the 4 geo levels, on the UI for creating & updating an individual/group, we will have the user select the location type, followed by the name. For individual import, we'd have two columns: location_type and location_name, instead of one village. These are all ok.

Using location also allows individuals and groups to have different levels of granularity defined for them. Would this be good for data consistency from a record keeping point of view? e.g. one might end up with a database of 20k individuals' locations at district level while another 10k at village level – would this serve program administration well? @andreamartin @malike @amschel-de-r would be good to have your thoughts on this.

Another minor-ish point to consider – What would be the validation logic on the relationship between the individual.location and individual.group.location when they are both present? Should they match exactly? One contains another? Would there be enrollment implications if group and individual locations do not match exactly?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah the concept of village as a geolocation is not available in all countries. The best approach will be to have labels which can be named at implementation level but tbh I don't know if its possible with the current openIMIS as the level of effort might be too big.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@malike the term "village" can be customized through translation on the UI. The decision point here is to either pin it down to the lowest geo level out of the 4 (most specific) or leave it flexible to allow it to be defined at any geo level for each individual/group.

Copy link

@amschel-de-r amschel-de-r Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good practice that every instance has one geographic level that is required for all individuals, i.e. no location_type flexibility. Having columns in data upload for location_type and location_name would add unnecessary confusion for users for all the countries I can think of. I would also be very surprised if there was a country that didn't have at least one geo level that all individuals have data on.

What I would say, is that it might be good for the level the mandatory field is at (i.e. geo_4, geo_3, geo_5) to be customizable in one place, maybe in core_moduleConfigurations or something. This would help the system fit both a larger country - where they may have complete data down to geo_6 level and users that need row-level security at geo_3 level - as well as a small island country - where they may only have 3 geo levels.

If we're talking edge cases, e.g. migrants, refugees and IDPs, I think we can encourage a practice where they allow for a catch-all value. E.g. if the geo levels are Region, District, Municipality and Village, and refugees/IDPs are handled at the Region level, then for each region you would create e.g. R1D_NA - no district, R1M_NA - no municipality, R1V_NA no village. Then in the data upload, you could set all the refugees/migrants in district 1 to have R1V_NA and they would be successfully handled (if I understand the feature).

As a side note, having either village or location as the mandatory column name would add confusion either way - do we have somewhere where we can customize a mapping between mandatory model fields and csv upload column names?

Open to comments - also have run this past Astrid as well

Comment on lines +45 to +46
prefix='village__parent__parent',
loc_types=['D']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will work fine but we might change it to support all locations to just prefix='location and no loc_types'

Suggested change
prefix='village__parent__parent',
loc_types=['D']

Comment on lines +51 to +52
prefix='groupindividual__group__village__parent__parent',
loc_types=['D']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
prefix='groupindividual__group__village__parent__parent',
loc_types=['D']
prefix='groupindividual__group__location'

Comment on lines +97 to +102
village = models.ForeignKey(
Location,
models.DO_NOTHING,
blank=True,
null=True
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
village = models.ForeignKey(
Location,
models.DO_NOTHING,
blank=True,
null=True
)
location = models.ForeignKey(
Location,
models.DO_NOTHING,
blank=True,
null=True,
related_name="groups",
)

Comment on lines +18 to +23
village = models.ForeignKey(
Location,
models.DO_NOTHING,
blank=True,
null=True
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
village = models.ForeignKey(
Location,
models.DO_NOTHING,
blank=True,
null=True
)
location = models.ForeignKey(
Location,
models.DO_NOTHING,
blank=True,
null=True,
related_name="individuals",
)

@@ -140,6 +152,15 @@ def _validate_mutation(cls, user, **data):
IndividualConfig.gql_individual_delete_perms):
raise ValidationError("mutation.authentication_required")

villages_qs = Location.objects.filter(individual__id__in=data['ids'], type='V')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what you are doing here filtering Location with individual_id (for id you need only one _)

Copy link
Contributor Author

@weilu weilu Oct 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for checking that the user has sufficient (geo) permission to delete the individual. See test case IndividualGQLMutationTest.test_delete_individual_row_security.

Good spotting on the double _. Fixed.

@@ -55,6 +58,7 @@ def resolve_recipient_type(self, info):
class CreateGroupInputType(OpenIMISMutation.Input):
code = graphene.String(required=True)
individuals_data = graphene.List(CreateGroupIndividualInputTypeInputObjectType, required=False)
village_id = graphene.Int(required=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment on locations

Comment on lines 155 to 162
villages_qs = Location.objects.filter(individual__id__in=data['ids'], type='V')
# must first check if villages_qs exists in case none of the individuals has location
if villages_qs.exists():
allowed_loc_ids = Location.get_queryset(None, user).values('id')
not_in_allowed = villages_qs.exclude(id__in=Subquery(allowed_loc_ids))
# all individuals' villages must be within permission for the given user
if not allowed_loc_ids.exists() or not_in_allowed.exists():
raise ValidationError("mutation.authentication_required")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be tested but you get the idea,

Suggested change
villages_qs = Location.objects.filter(individual__id__in=data['ids'], type='V')
# must first check if villages_qs exists in case none of the individuals has location
if villages_qs.exists():
allowed_loc_ids = Location.get_queryset(None, user).values('id')
not_in_allowed = villages_qs.exclude(id__in=Subquery(allowed_loc_ids))
# all individuals' villages must be within permission for the given user
if not allowed_loc_ids.exists() or not_in_allowed.exists():
raise ValidationError("mutation.authentication_required")
individual_not_allowed = Individual.objects.filters(
id__in=data['ids'],
Q(~location__in=LocationManager().allowed(user=user))
)
if individual_not_allowed:
raise ValidationError("mutation.authentication_required")

@@ -175,6 +196,15 @@ def _validate_mutation(cls, user, **data):
IndividualConfig.gql_individual_undo_delete_perms):
raise ValidationError("mutation.authentication_required")

villages_qs = Location.objects.filter(individual__id__in=data['ids'], type='V')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed unnecessary __

@@ -232,6 +266,9 @@ def _validate_mutation(cls, user, **data):
if not user.has_perms(
IndividualConfig.gql_group_update_perms):
raise ValidationError("mutation.authentication_required")
village = Group.objects.get(id=data['id']).village
if village and village not in Location.get_queryset(None, user):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBC id allowed won't be better

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean?

@weilu
Copy link
Contributor Author

weilu commented Oct 19, 2024

@delcroip I see you merged these changes into 24.10 release branch. Should 24.10 be merged to develop and this PR be discarded?

@weilu
Copy link
Contributor Author

weilu commented Oct 19, 2024

Closing this as the change set is included in #130

@weilu weilu closed this Oct 19, 2024
@weilu weilu deleted the row-security branch October 19, 2024 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add row-level security to individual and group
5 participants