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

Support app-specific web apps permissions for web apps users #34421

Merged
merged 30 commits into from Apr 30, 2024

Conversation

orangejenny
Copy link
Contributor

@orangejenny orangejenny commented Apr 11, 2024

Product Description

Overview

https://dimagi.atlassian.net/browse/USH-4289

This work overlaps somewhat with the Login As changes @MartinRiese is doing in #34351 This description is written assuming that those changes are complete. I'm planning to wait on QAing this PR until that work is complete.

Old behavior

There's a single permission in roles & permissions that controls access to web apps.

Screenshot 2024-04-11 at 2 17 51 PM

This permission is checked for the user who is logged into HQ. When Login As is used, permissions of the Login As user are ignored. Also, the web apps permission is not checked if the user logged into HQ is a mobile user. The default role for mobile workers doesn't even include web apps access (see here). All of this is controlled by the @require_cloudcare_access decorator, which is applied to cloudcare views.

In addition, access can be granted to specific applications in web apps using a bespoke set of models and UI:

Screenshot 2024-04-11 at 2 24 22 PM

This is based on mapping applications to user groups. User groups are only available for mobile users. Therefore, app-specific permissions are only available for mobile users.

Impetus for change

As part of the work to move web apps projects away from depending on linked mobile users, we need to support app-specific permissions for web users.

There's an alternative solution where we add support for web users to user groups. This is discussed in the ticket and linked documents, but in short, it was rejected in favor of integrating app-specific web apps permissions with our general roles & permissions framework.

New behavior

I'm clubbing a couple of related changes into this PR in the interest of getting holistic review. I'm happy to split things up if reviewers would find that helpful or believe it would be safer.

  1. Add app-specific permissions to roles & permissions (first few commits)

This is a straightforward addition to our existing roles & permissions framework.
Screenshot 2024-04-11 at 2 35 13 PM

  1. Add feature flag to hide old group-based behavior (next few commits)

Having two ways to assign app-specific permissions is confusing. Moving from group-based to role-based permissions requires human intervention. As a compromise, I'm adding a feature flag, marked deprecated, to gate the old UI. Migration 0059 turns this flag on for all domains that currently have an ApplicationAccess object that's restricting app access. I added a warning to the top of the old UI, but I'm not changing behavior at all. ApplicationAccess restrictions will continue to be applied as they are today, in addition to the new app-specific permissions.

3. Update default mobile worker role to access web apps, and remove mobile worker check (last few commits)

This is the broadest/scariest change. Pushing users to use roles to manage app-specific permissions, for both web and mobile workers, makes it more glaringly apparent that the existing web apps permission doesn't apply to mobile workers. To reduce ambiguity, I'm updating require_cloudcare_access to check the permission for both web and mobile users. To avoid a change in behavior, migration 0060 updates all of the default mobile worker roles to turn on web apps access.

How do these changes combine? I'm continuing to decorate cloudcare views with a permissions check on the HQ user, so if you log into HQ and don't have any cloudcare permissions, you always 403, no opportunity to login as someone else. If you can log into HQ and then use login as, that user's permissions are used to determine which apps you see. This does mean that you can log into HQ as a user who has access only to app A, and can login as a user who has access to app B.

Summary of behavior:

HQ user access_web_apps Login As user access_web_apps Behavior
False no login as 403
app-specific no login as display apps HQ user has access to
False no apps displayed
app-specific display apps login as user has access to
True all apps displayed
True no login as all apps displayed
False no apps displayed
app-specific display apps login as user has access to
True all apps displayed

In addition to this, any legacy ApplicationAccess restrictions will continue to be applied, for backwards compatibility.

Feature Flag

Introduces WEB_APPS_PERMISSIONS_VIA_GROUPS, but most of this behavior is not gated.

Safety Assurance

Safety story

Permissions are always sensitive, and this does migrations and changes default-type behavior, so it feels medium-high risk.

Automated test coverage

There are tests for roles and permissions.

QA Plan

https://dimagi.atlassian.net/browse/QA-6352

Migrations

  • The migrations in this code can be safely applied first independently of the code

No? The migrations are integrated with the code. I'm not terribly concerned that someone will add an ApplicationAccess object during deploy, after migration 0059 runs but before the new code that restricts the config UI based on that feature flag goes live.

I could pull the application access changes (the new feature flag, migration 0059, and the changes that restrict the old config UI) into a separate PR, or even two separate PRs (one for the flag + migration, a second for the UI restrictions). I'm not going to do that unless anyone else is concerned about the deploy window, because I think the risk here is low.

For the 0060 migration for the default mobile worker role, the current behavior is at best ambiguous and arguably incorrect, since a mobile worker with a role that doesn't have access to web apps still gets access to web apps. That makes me not concerned about default mobile worker roles getting updated during deploy.

Rollback instructions

  • This PR can be reverted after deploy with no further considerations

Rolling back will not be possible as soon as people start adding app-specific permissions to the database, as the wrap function will start to fail.

Labels & Review

  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the level of risk of the change

@orangejenny orangejenny added awaiting QA QA in progress. Do not merge product/all-users-all-environments Change impacts all users on all environments Risk: Medium Change affects files that have been flagged as medium risk. labels Apr 11, 2024
@dimagimon dimagimon added the reindex/migration Reindex or migration will be required during or before deploy label Apr 11, 2024
@orangejenny orangejenny force-pushed the jls/web-apps-specific-permissions branch from 8de9ec2 to bc74d48 Compare April 11, 2024 20:19
@orangejenny orangejenny force-pushed the jls/web-apps-specific-permissions branch from bc74d48 to 16ddf89 Compare April 11, 2024 20:20
Copy link
Contributor

@esoergel esoergel left a comment

Choose a reason for hiding this comment

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

Exemplary PR, bravo! Great description, commit breakdown, etc

@@ -267,22 +279,6 @@ hqDefine('users/js/roles',[
allowCheckboxId: null,
allowCheckboxPermission: null,
},
{
showOption: root.webAppsPrivilege,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing this was for projects that have plans without access to web apps (is that Community?). Removing this restriction will mean that those projects will now be able to configure web apps permissions even when not applicable. Probably not a big deal, just wanted to check.

Copy link
Contributor

Choose a reason for hiding this comment

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

nevermind, I see it was functionally replaced with
{% if request|request_has_privilege:"CLOUDCARE" %}
below

Comment on lines 210 to 212
<legend>
{% trans "Web Apps" %}
</legend>
Copy link
Contributor

Choose a reason for hiding this comment

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

Did this look like a decent target to extract into a template? Not sure how much it has in common with the other parameterized permissions

@@ -510,20 +510,6 @@ def can_restrict_access_by_location(self):
return self.domain_object.has_privilege(
privileges.RESTRICT_ACCESS_BY_LOCATION)

@property
@memoized
def release_management_privilege(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

nice cleanup commit

@@ -556,3 +478,43 @@ <h4 class="modal-title" data-bind="text: modalTitle"></h4>
</form>
</div>
</div>

<script type="text/html" id="permission_all_selected_none">
Copy link
Contributor

Choose a reason for hiding this comment

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

lol, you're always a step ahead

Copy link
Contributor Author

Choose a reason for hiding this comment

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

😀

Comment on lines 10 to 11
for access in ApplicationAccess.objects.filter(restrict=True):
WEB_APPS_PERMISSIONS_VIA_GROUPS.set(access.domain, enabled=True, namespace=NAMESPACE_DOMAIN)
Copy link
Contributor

Choose a reason for hiding this comment

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

are you anticipating this being fast enough to run during the deploy?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, with only a couple hundred ApplicationAccess objects on prod this should be fast

@@ -944,6 +944,13 @@ def _ensure_valid_randomness(randomness):
"""
)

WEB_APPS_PERMISSIONS_VIA_GROUPS = StaticToggle(
Copy link
Contributor

Choose a reason for hiding this comment

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

Might throw in a description pointing to the alternative, unless you did that on the page itself

{% blocktrans %}
This feature is deprecated. Access to specific web apps can now be managed via
<a target="_blank" href="{{ roles_url }}">Roles & Permissions</a>.
{% endblocktrans %}
Copy link
Contributor

Choose a reason for hiding this comment

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

lol, yup there it is

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a good point that the flag itself could be more descriptive, updated in 7aa89a6


@skip_on_fresh_install
def enable_flag(apps, schema_editor):
for access in ApplicationAccess.objects.filter(restrict=True):
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this as targeted as we can safely do? Like, would it make sense to check whether the domain has access to web apps or groups configured or some such? I guess that could also be a clean-up pass done separately later

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it's pretty harmless, the view itself is still decorated to require the cloudcare privilege. The risks here are:

  • Block access to this page for a domain that should have it: I think this is unlikely, although I could remove the restrict=True filter.
  • Accidentally give access to a domain that doesn't need it: not a big deal, since right now all domains (with cloudcare privilege) can access this page

Copy link
Contributor

@esoergel esoergel Apr 12, 2024

Choose a reason for hiding this comment

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

Accidentally give access to a domain that doesn't need it: not a big deal, since right now all domains (with cloudcare privilege) can access this page

Yeah, I agree - this is the situation I was thinking of. Fewer domains on this flag means that it's easier to eventually remove in the future, but I agree it's probably better to play it safe here and work on whittling down the list separately whenever we get around to it



@skip_on_fresh_install
def _update_default_mobile_worker_role(apps, schema_editor):
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm also a bit nervous about the access change associated with this. There's always the possibility that there are mobile workers using roles other than the default and using web apps. I do think the current behavior is arguably a bug (if the role explicitly doesn't have web apps access...), so I think we have some wiggle room. Still, is there anything we could do to de-risk this, or get a sense of how many users it might realistically impact?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Point well taken. I'll do some digging on this and report back.

(You reviewed this a lot faster than I was expecting - thank you!)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

reverted this change, see longer comment below

Copy link
Contributor

@snopoke snopoke left a comment

Choose a reason for hiding this comment

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

This is a great change, thanks! If a web user doesn't have access to any apps can they still get the Web Apps page and use login as?

corehq/apps/cloudcare/views.py Outdated Show resolved Hide resolved
corehq/tabs/tabclasses.py Outdated Show resolved Hide resolved
@MartinRiese
Copy link
Contributor

Can't add anything useful. But it is good learning to read through the PR and Ethan's comments.

@Robert-Costello
Copy link
Contributor

Can't add anything useful. But it is good learning to read through the PR and Ethan's comments.

Same here. Reading through this PR and the comments is especially helpful since I'm starting on adjacent permissions work for tableau access.

Even though this isn't checked, it's better for it to reflect actual behavior,
which is that mobile workers all get web apps access.
@orangejenny
Copy link
Contributor Author

I'm reverting the last few commits, which change the mobile worker default role and remove the is_commcare_user checks for web apps access, as I don't believe they're safe to make without substantially more due diligence and also likely a slower rollout that's explicitly communicated to users.

Many mobile workers have no role set. This makes sense, since the default role is a relatively recent addition and we're lazily migrating mobile workers to use it (see #32057). For those mobile workers, if you ask for their role, you get the default role back (see #32008), but directly accessing their permissions doesn't go through that default role:

> user = CouchUser.get_by_user_id('<a_user_id_with_no_role_set>')

> user.domain_membership.role_id   # no role set directly on the domain membership

> user.domain_membership.role  # access role on object, get back default role, great
UserRole(domain='3156-down', name='Mobile Worker Default')

> user.get_domain_membership('3156-down').role.permissions.access_web_apps  # default role can access web apps, great
True

> user.can_access_web_apps()  # checking permissions on user does not use default role, alas
False

That means the migration to add access_web_apps to all default mobile worker roles is insufficient to actually grant them access to web apps. We'd need to either do a non-lazy migration to assign roles to all mobile workers, or update permissions checking so that it respects the default role if no role is explicitly set. Both of those are out of scope.

Copy link
Contributor

Choose a reason for hiding this comment

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

Does removing this file have any implications for live environments (possibly staging)? Should the change be reversed?

Also, what happens when a future 0061 migration is added to this app? Will it appear to have been applied in any environment where this was applied? Maybe this has not been applied anywhere, in which case there is no concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Important questions, and yes, this migration had already been run on staging. I ran migrate users 0060 on staging to "undo" the migration from django's perspective, and then undid the changes in a shell (running the code that was in the migration but flipping access_web_apps to False).

@esoergel
Copy link
Contributor

esoergel commented Apr 18, 2024

@orangejenny that behavior looks very bizarre to me. I just took a look and I think this is a bug, though one that shouldn't affect authorization. Check this out:

>>> user.can_access_web_apps()
False
>>> user.can_access_web_apps(self.domain)
True

That's the core of the problem, I believe. can_access_web_apps goes through __getattr__ and _get_perm_check_fn

def __getattr__(self, item):
if item.startswith('can_'):
perm = item[len('can_'):]
if perm:
fn = self._get_perm_check_fn(perm)
fn.__name__ = item
return fn
raise AttributeError("'{}' object has no attribute '{}'".format(
self.__class__.__name__, item))
def _get_perm_check_fn(self, perm):
def fn(domain=None, data=None):
domain = domain or getattr(self, 'current_domain', None)
return self.has_permission(domain, perm, data)
return fn

So the two calls above resolve to

>>> user.has_permission(None, 'access_web_apps')
False
>>> user.has_permission(self.domain, 'access_web_apps')
True

That is, this will work if domain is passed to the can_ call, or if it is in a request context, where current_domain is populated. This is a gross inconsistency in the code.

@esoergel
Copy link
Contributor

(I do still support your decision to separate the main changes from addressing this stuff though)

@orangejenny
Copy link
Contributor Author

@esoergel OH. That makes sense. Yeah, my testing was bad. Which is a relief. But yeah, I think I'm going to keep those changes reverted, I'm just gun-shy about sweeping permissions changes.

Comment on lines +437 to +440
permissions.web_apps_list = [
downstream_id for downstream_id, upstream_id in app_id_map.items()
if upstream_id in permissions.web_apps_list
]
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume this new usage of app ids does not require any data migrations since this is a new feature that is only deployed on staging where old data either does not exist or does not matter. Is that right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, exactly.

Copy link
Contributor

@esoergel esoergel left a comment

Choose a reason for hiding this comment

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

Good catch with the linked domains thing, that wasn't on my radar

def _can_access_web_app(user, domain, app):
if user.can_access_web_app(domain, app.get('copy_of', app.get('_id'))):
return True
if user.can_access_web_app(domain, app.get('upstream_app_id')):
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you elaborate on this one? This is for the situation where there are two apps on the same domain, one linked to the other? Would these show up as different entries on the roles page where you select which apps are accessible?

Comment on lines +240 to +241
role = UserRole.objects.get_by_domain(self.downstream_domain)[0]
self.assertListEqual(role.permissions.web_apps_list, [downstream_app.id])
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah this clarifies it, thank you. In my words, it's that a role on an upstream domain with access to an app needs to be propagated to the downstream domain with the app access updated to the corresponding downstream app. Did I get that right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yep, exactly

@orangejenny orangejenny added QA Passed and removed awaiting QA QA in progress. Do not merge labels Apr 29, 2024
@orangejenny orangejenny merged commit 7bbfe26 into master Apr 30, 2024
12 of 13 checks passed
@orangejenny orangejenny deleted the jls/web-apps-specific-permissions branch April 30, 2024 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
product/all-users-all-environments Change impacts all users on all environments QA Passed reindex/migration Reindex or migration will be required during or before deploy Risk: Medium Change affects files that have been flagged as medium risk.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants