Skip to content

Commit a52f934

Browse files
authored
Merge pull request #28493 from dimagi/cz/role-ui
add ability to create domain-scoped API keys
2 parents f25fc02 + 2799e5b commit a52f934

File tree

7 files changed

+92
-10
lines changed

7 files changed

+92
-10
lines changed

corehq/apps/api/tests/test_auth.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ def setUpClass(cls):
2222
cls.password = '***'
2323
cls.user = WebUser.create(cls.domain, cls.username, cls.password, None, None)
2424
cls.api_key, _ = HQApiKey.objects.get_or_create(user=WebUser.get_django_user(cls.user))
25+
cls.domain_api_key, _ = HQApiKey.objects.get_or_create(user=WebUser.get_django_user(cls.user),
26+
name='domain-scoped',
27+
domain=cls.domain)
2528

2629
@classmethod
2730
def tearDownClass(cls):
@@ -85,6 +88,19 @@ def test_auth_type_basic(self):
8588

8689
class LoginAndDomainAuthenticationTest(AuthenticationTestBase):
8790

91+
@classmethod
92+
def setUpClass(cls):
93+
super().setUpClass()
94+
cls.domain2 = 'api-test-other'
95+
cls.project2 = Domain.get_or_create_with_name(cls.domain2, is_active=True)
96+
cls.user.add_domain_membership(cls.domain2, is_admin=True)
97+
cls.user.save()
98+
99+
@classmethod
100+
def tearDownClass(cls):
101+
cls.project2.delete()
102+
super().tearDownClass()
103+
88104
def test_login_no_auth_no_domain(self):
89105
self.assertAuthenticationFail(LoginAndDomainAuthentication(), self._get_request())
90106

@@ -94,6 +110,32 @@ def test_login_no_auth_with_domain(self):
94110
def test_login_with_domain(self):
95111
self.assertAuthenticationSuccess(LoginAndDomainAuthentication(),
96112
self._get_request_with_api_key(domain=self.domain))
113+
self.assertAuthenticationSuccess(LoginAndDomainAuthentication(),
114+
self._get_request_with_api_key(domain=self.domain2))
115+
116+
def test_login_with_domain_key(self):
117+
self.assertAuthenticationSuccess(
118+
LoginAndDomainAuthentication(),
119+
self._get_request(
120+
self.domain,
121+
HTTP_AUTHORIZATION=self._construct_api_auth_header(
122+
self.username,
123+
self.domain_api_key
124+
)
125+
)
126+
)
127+
128+
def test_login_with_domain_key_wrong(self):
129+
self.assertAuthenticationFail(
130+
LoginAndDomainAuthentication(),
131+
self._get_request(
132+
self.domain2,
133+
HTTP_AUTHORIZATION=self._construct_api_auth_header(
134+
self.username,
135+
self.domain_api_key
136+
)
137+
)
138+
)
97139

98140
def test_login_with_wrong_domain(self):
99141
project = Domain.get_or_create_with_name('api-test-fail', is_active=True)

corehq/apps/settings/forms.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,21 +257,31 @@ def __init__(self, **kwargs):
257257

258258

259259
class HQApiKeyForm(forms.Form):
260+
ALL_DOMAINS = ''
260261
name = forms.CharField()
261262
ip_allowlist = SimpleArrayField(
262263
forms.GenericIPAddressField(),
263264
label=ugettext_lazy("Allowed IP Addresses (comma separated)"),
264265
required=False
265266
)
267+
domain = forms.ChoiceField(
268+
required=False,
269+
help_text=ugettext_lazy("Limit the key's access to a single project space")
270+
)
266271

267272
def __init__(self, *args, **kwargs):
273+
self.couch_user = kwargs.pop('couch_user')
268274
super().__init__(*args, **kwargs)
269275

276+
user_domains = self.couch_user.get_domains()
277+
all_domains = (self.ALL_DOMAINS, _('All Projects'))
278+
self.fields['domain'].choices = [all_domains] + [(d, d) for d in user_domains]
270279
self.helper = HQFormHelper()
271280
self.helper.layout = Layout(
272281
crispy.Fieldset(
273282
ugettext_lazy("Add New API Key"),
274283
crispy.Field('name'),
284+
crispy.Field('domain'),
275285
crispy.Field('ip_allowlist'),
276286
),
277287
hqcrispy.FormActions(
@@ -292,6 +302,7 @@ def create_key(self, user):
292302
name=self.cleaned_data['name'],
293303
ip_allowlist=self.cleaned_data['ip_allowlist'],
294304
user=user,
305+
domain=self.cleaned_data['domain'] or '',
295306
)
296307
return new_key
297308

corehq/apps/settings/templates/settings/user_api_keys.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<script type="text/html" id="base-user-api-key-template">
99
<td data-bind="text: name"></td>
1010
<td data-bind="text: key"></td>
11+
<td data-bind="text: domain"></td>
1112
<td data-bind="text: ip_allowlist"></td>
1213
<td data-bind="text: created"></td>
1314
<td>
@@ -59,7 +60,9 @@ <h3>
5960
<script type="text/html" id="new-user-api-key-template">
6061
<td data-bind="text: name"></td>
6162
<td data-bind="text: key"></td>
63+
<td data-bind="text: domain"></td>
6264
<td data-bind="text: ip_allowlist"></td>
6365
<td data-bind="text: created"></td>
66+
<td></td>
6467
</script>
6568
{% endblock %}

corehq/apps/settings/views.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ def column_names(self):
528528
return [
529529
_("Name"),
530530
_("API Key"),
531+
_("Project"),
531532
_("IP Allowlist"),
532533
_("Created"),
533534
_("Delete"),
@@ -546,6 +547,7 @@ def paginated_list(self):
546547
"id": api_key.id,
547548
"name": api_key.name,
548549
"key": redacted_key,
550+
"domain": api_key.domain or _('All Projects'),
549551
"ip_allowlist": (
550552
", ".join(api_key.ip_allowlist)
551553
if api_key.ip_allowlist else _("All IP Addresses")
@@ -562,8 +564,8 @@ def post(self, *args, **kwargs):
562564

563565
def get_create_form(self, is_blank=False):
564566
if self.request.method == 'POST' and not is_blank:
565-
return HQApiKeyForm(self.request.POST)
566-
return HQApiKeyForm()
567+
return HQApiKeyForm(self.request.POST, couch_user=self.request.couch_user)
568+
return HQApiKeyForm(couch_user=self.request.couch_user)
567569

568570
def get_create_item_data(self, create_form):
569571
try:
@@ -576,6 +578,7 @@ def get_create_item_data(self, create_form):
576578
'id': new_api_key.id,
577579
'name': new_api_key.name,
578580
'key': f"{new_api_key.key} ({copy_key_message})",
581+
"domain": new_api_key.domain or _('All Projects'),
579582
'ip_allowlist': new_api_key.ip_allowlist,
580583
'created': new_api_key.created.isoformat()
581584
},

corehq/apps/users/admin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@ class DomainPermissionsMirrorAdmin(admin.ModelAdmin):
4545

4646

4747
admin.site.register(DomainPermissionsMirror, DomainPermissionsMirrorAdmin)
48+
49+
50+
class HQApiKeyAdmin(admin.ModelAdmin):
51+
list_display = ['user', 'name', 'created', 'domain']
52+
list_filter = ['created', 'domain']
53+
54+
55+
admin.site.register(HQApiKey, HQApiKeyAdmin)

corehq/apps/users/decorators.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,30 @@ def require_api_permission(permission, data=None, login_decorator=login_and_doma
7777
permissions_to_check = {permission, api_access_permission}
7878

7979
def permission_check(request, domain):
80-
if getattr(request, 'api_key', None) and request.api_key.role:
81-
role = request.api_key.role
80+
# first check user permissions and return immediately if not valid
81+
user_has_permission = all(
82+
request.couch_user.has_permission(domain, p, data=data)
83+
for p in permissions_to_check
84+
)
85+
if not user_has_permission:
86+
return False
87+
88+
# then check domain and role scopes, if present
89+
api_key = getattr(request, 'api_key', None)
90+
91+
if not api_key:
92+
return True # only api keys support additional checks
93+
elif api_key.role:
8294
return (
83-
role.domain == domain
84-
and all(request.couch_user.has_permission(domain, p, data=data)
85-
for p in permissions_to_check)
86-
and all(role.permissions.has(p, data) for p in permissions_to_check)
95+
api_key.role.domain == domain
96+
and all(api_key.role.permissions.has(p, data) for p in permissions_to_check)
8797
)
98+
elif api_key.domain:
99+
# we've already checked for user and role permissions so all that's left is domain matching
100+
return domain == api_key.domain
88101
else:
89-
return all(request.couch_user.has_permission(domain, p, data=data)
90-
for p in permissions_to_check)
102+
# unscoped API key defaults to user permissions
103+
return True
91104

92105
return require_permission_raw(
93106
None, login_decorator,

corehq/apps/users/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3081,4 +3081,6 @@ def role(self):
30813081
return UserRole.get(self.role_id)
30823082
except ResourceNotFound:
30833083
logging.exception('no role with id %s found in domain %s' % (self.role_id, self.domain))
3084+
elif self.domain:
3085+
return CouchUser.from_django_user(self.user).get_domain_membership(self.domain).role
30843086
return None

0 commit comments

Comments
 (0)