Require client visibility to view enrollment in HMIS; update HMIS permission docs - #6772
Require client visibility to view enrollment in HMIS; update HMIS permission docs#6772gigxz wants to merge 26 commits into
Conversation
…-enrollment-visibility
martha
left a comment
There was a problem hiding this comment.
Looks good to me! Thanks especially for the updated documentation! ✨
There was a problem hiding this comment.
Should this be moved to docs/features/hmis/hmis-permissions.md?
|
|
||
| `viewable_by` only answers "can this user see this record?" It never implies permission to edit or delete, which is why the policy check is still required. | ||
|
|
||
| An entity is **directly viewable** when a Collection the user reaches through an AccessControl references it and the attached Role grants a viewable permission. It is **inherited** when one of its parents is directly viewable: a Project is viewable through its Organization, Data Source, or a Project Group containing it; an Organization is viewable through its Data Source. |
There was a problem hiding this comment.
If I understand right, this isn't just true of viewability, and the general concept of direct vs. inherited permission on an entity feels like it might belong in a different section. Close to line 26 at the end of the Core Concepts section, maybe?
There was a problem hiding this comment.
Curious why this doc stays here instead of getting centralized in docs/features. Is it because this doc is more technical with code examples? If that's true, have we documented this desired documentation structure? :)
|
|
||
| context 'when user can view enrollments at the target project' do | ||
| let!(:access_control) { create_access_control(user, project, with_permission: [:can_view_project, :can_view_enrollment_details]) } | ||
| let!(:access_control) { create_access_control(user, project, with_permission: [:can_view_project, :can_view_enrollment_details, :can_view_clients]) } |
There was a problem hiding this comment.
any reason to not use HmisPermissionSets::ENROLLMENT_VISIBILITY here? I have the same question in a few places in the below specs too.
| # Whether the user can view some enrollments with full details | ||
| # | ||
| # Note: "can_view_enrollment_details" requires the "can_view_project" and "can_view_clients" permissions | ||
| # as dependencies, so the user must have both at SOME projects (not necessarily the same project). |
There was a problem hiding this comment.
| # as dependencies, so the user must have both at SOME projects (not necessarily the same project). | |
| # as dependencies, so the user must have all 3 at SOME projects (not necessarily the same project). |
| describe 'files_viewable_by scope' do | ||
| let!(:user) { create(:hmis_user, data_source: ds1) } | ||
| let(:file_perms) { [:can_view_any_nonconfidential_client_files, :can_view_any_confidential_client_files, :can_view_clients] } | ||
| let(:enrollment_view_perms) { [:can_view_enrollment_details, :can_view_project] } |
There was a problem hiding this comment.
| let(:enrollment_view_perms) { HmisPermissionSets::ENROLLMENT_VISIBILITY } |
Prompted by AI review:
[warning]enrollment_access_spec.rb:239— thefiles_viewable_byblock was not updated.enrollment_view_permsis still[:can_view_enrollment_details, :can_view_project], butfiles_viewable_byroutes throughenrollment_details_viewable_by, which now requirescan_view_clientson the same role. Two examples are affected. At:246, "is empty if the user has enrollment access but no file permissions" grants onlyenrollment_view_perms, so the user no longer has enrollment access either and the assertion holds for the wrong reason. At:252, the p2 access control ("no file perms") is likewise missing enrollment visibility. Both still pass, but neither tests file permissions anymore. Adding:can_view_clientstoenrollment_view_perms— or usingHmisPermissionSets::ENROLLMENT_VISIBILITY— restores the intended premise.
| describe 'user has project access to p1, but no enrollment access' do | ||
| let!(:access_control) { create_access_control(user, p1, with_permission: [:can_view_project]) } | ||
| # cruft: give this user can_view_enrollment_details at another project, so we don't hit the early-return optimization | ||
| let!(:cruft_access_control) { create_access_control(user, p6, with_permission: [:can_view_enrollment_details]) } |
There was a problem hiding this comment.
| let!(:cruft_access_control) { create_access_control(user, p6, with_permission: HmisPermissionSets::ENROLLMENT_VISIBILITY) } |
prompted by AI review:
[warning]enrollment_access_spec.rb:167— the "cruft" access control is there specifically to defeat the early return inviewable_by(return none unless global_policy.can_view? || global_policy.can_view_limited?,enrollment.rb:176). It grants onlycan_view_enrollment_details, which is now stripped fromglobal_permissionsfor lackingcan_view_clients, socan_view?is false and the early return fires. The example passes without ever reaching the scope logic it was written to exercise. Add:can_view_clientsto that grant.
|
|
||
| `HmisPermissionLoader` drops any permission whose chain isn't fully granted, so an unmet requirement leaves the permission absent from the set and every policy predicate behaves as if it was never granted. `UserContext#project_permissions` evaluates this per project; `UserContext#global_permissions` evaluates it across the whole data source, where a chain split across projects can over-report — which is why it only informs coarse UI decisions. | ||
|
|
||
| The lower-level scopes (`User#entities_with_permissions`, `Project.with_access`) match Role columns directly and don't resolve requirements. They take `mode: :any` (the default) or `mode: :all`. Pass the flattened chain when a scope needs it. That is intentionally stricter than policy evaluation, which checks requirements against the union of a user's roles: `mode: :all` requires them on a single role, so a role granting enrollment visibility without client visibility grants no enrollment access rather than borrowing the prerequisite from elsewhere. |
There was a problem hiding this comment.
Here's a sort of amorphous thought that I want to post even though it's not in scope for this PR. Could we create a scope living between the with_access layer and the viewable_by scopes, that would handle permissions requirements consistently under the hood?
Currently,
with_access- resolves raw permissions, ignoring requirements. So in a way, "with access" is kinda a misnomer, because it doesn't really tell you what the user has actual access to see/do, it just tells you what raw permissions they have from their roles.viewable_by- each one needs to remember to pass in all the requirements, even though the requirement information already lives on the role. I guess the point of the current design is that theviewable_byscopes are the nice api that I'm wishing for. But can we centralize it better? -- similar to how, when implementing a new policy, you don't have to restate every permission requirement, it's just handled already at a lower level.
I'm not quite sure, but maybe we are talking about the same thing here?:
Memoize
Role.permissions_with_descriptionsand expose a helperrequired_permissions_for(perm)that can be used byHmisPermissionLoader#apply_permission_requirements. I left this out because it felt like scope creep, but I think it would be a beneficial refactor
Here are some notes from AI trying to help me think through this idea and propose a concrete change, although I'm not sure I have totally wrapped my head around the second suggestion in particular. But let me know what you think!
It would be nice if requirement resolution were a property of the scope layer rather than a call-site convention.
The doc as written states the rule as something to remember — "Pass the flattened chain when a scope needs it" — and this PR has to add
with_access(user, :can_view_enrollment_details, :can_view_project, :can_view_clients, mode: :all), which is a hand-maintained copy of a chain the system already knows (can_view_enrollment_details'srequirements). When the answer to a design question is "remember to do X," it's worth asking whether X can be done once.Right now the
*_viewable_byscopes are the intended answer, andwith_enrollment_details_viewable_byhere is a good instance of it. But they're opt-in:with_accessstays public and is the easiest thing to reach for, and the chain gets re-derived by hand at each call site. Existing callers show the drift that allows:
Types::Forms::PickListOption(ENROLLABLE_PROJECTS) usesviewable_by(user).with_access(user, :can_enroll_clients), which matches only that one Role column. The policy gating the form (HmisProjectPolicy#can_create_enrollments?→project_permissions.include?(:can_edit_enrollments)) resolves the full chain down tocan_view_clients. So the pick list can offer a project whose enrollment form submission is then denied.StaffAssignmentPolicy::Global#can_index?callsProject.with_access(user, :can_edit_enrollments)— a policy that skips the requirement resolution the policy layer otherwise promises.Client.files_viewable_byandEnrollment.files_viewable_bychain twowith_accesscalls, socan_view_any_*_client_files'can_view_clientsrequirement is satisfied by whatever role the other call matched. Which is to say: the same-role invariant only holds within a singlewith_accesscall, so chaining already mixes the two semantics.Two levels of fix, and the first seems small:
- Flatten inside
entities_with_permissions. With theRole.required_permissions_for(perm)helper you already listed as a follow-up,with_access(user, :can_view_enrollment_details, mode: :all)could mean what a reader assumes, and bothwith_enrollment_details_viewable_byandHmisPermissionSetswould become unnecessary. The wrinkle ismode: :any:with_access(user, :a, :b, mode: :any)would need to become(a AND a's reqs) OR (b AND b's reqs), a disjunction of conjunctions thatRole.with_any_permissionscan't express today. But that is the semanticsHmisPermissionLoaderalready implements in Ruby, so we'd mostly be making the SQL agree with it.- Have one evaluator. The deeper version of your first follow-up: there are two independent implementations of "what does this user have here" —
Role.with_permissionsmatching columns in SQL, andHmisPermissionLoaderbuilding a Set in Ruby. Requirements are just where they first disagree; per-role versus union-across-roles is the second place. Sincewith_accessalready plucks ids intowhere(id: ...), we aren't getting much SQL composability from it, so a scope could instead askUserContextfor the project ids whoseproject_permissionsinclude a permission, backed by a bulk load over the user's project universe. Most of the machinery exists (HmisProjectAccessGroupLoader,preload_project_dependencies,cached_viewable_project_ids). Then the scope and the policy can't drift, because only one thing resolves permissions.
Merging this PR
mainstagingandproductionDescription
Issue: https://github.com/open-path/Green-River/issues/9442
Enrollment visibility now requires
can_view_clientsin addition to project/enrollment-detail permissions, closing a gap where a user without client visibility could still queryenrollment(id:)and resolve related records (assessments, services, CLS, SOGI). Limited enrollment summaries on the client dashboard get the same requirement.Approach: The check is expressed declaratively in
Hmis::Role.permissions_with_descriptions(requirements:), so policy objects inherit it for free via HmisPermissionLoader, plus explicit additions to the viewable_by scope chain.Note on same-role semantics: Enrollment visibility is resolved with with_access(..., mode: :all), so
can_view_enrollment_details,can_view_project, andcan_view_clientsmust all be granted on the same role rather than unioned across a user's roles. This is intentional: a role granting enrollment details without client visibility is an incomplete grant, and satisfying it from an unrelated role would make a role's effective access depend on which other roles a user happens to hold. All production environments have been audited, and no existing role grants enrollment visibility in a way that loses access under this change.Documentation: moves
drivers/hmis/doc/PERMISSIONS.mdtodocs/features/hmis-permissions.md, updates it to mirror format ofwarehouse-permissions.md; add explanation of how "requirements" work; focus it on helping devs understand how best to check permissions (and what's legacy vs not)Follow-up work needed
Enrollment#viewable_byandHmisEnrollmentPolicy#can_view_details?still diverge.viewable_byusesProject.with_access(user, :can_view_enrollment_details, :can_view_project, :can_view_clients, mode: :all)under the hood, which requires that all three permissions exist on the same role. Meanwhile Policies useUserContext#project_permissionswhich allows all three permissions to exist on different roles, as long as they all apply to the project.can_view_clients.requirementsin the HMIS Role interface; and prevent a user from saving a Role that grants a permission without it's requirements. This will make things clearer to admins.Role.permissions_with_descriptionsand expose a helperrequired_permissions_for(perm)that can be used byHmisPermissionLoader#apply_permission_requirements. I left this out because it felt like scope creep, but I think it would be a beneficial refactorType of change
Bug fix
Checklist before requesting review