From 531df3f4410ff124e9d4fd1889bc1fe6682c53ee Mon Sep 17 00:00:00 2001 From: Jing Cheng Date: Tue, 17 Dec 2024 14:04:19 -0500 Subject: [PATCH 1/5] Add API Usage Tile --- corehq/apps/enterprise/enterprise.py | 40 ++++++++++++++++++++++++++-- corehq/apps/enterprise/views.py | 6 ++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/corehq/apps/enterprise/enterprise.py b/corehq/apps/enterprise/enterprise.py index eba975f9decc..9b79366d99e5 100644 --- a/corehq/apps/enterprise/enterprise.py +++ b/corehq/apps/enterprise/enterprise.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from django.conf import settings +from django.contrib.auth.models import User from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy @@ -36,7 +37,7 @@ get_mobile_user_count, get_web_user_count, ) -from corehq.apps.users.models import CouchUser, Invitation +from corehq.apps.users.models import CouchUser, HQApiKey, Invitation class EnterpriseReport(ABC): @@ -47,6 +48,7 @@ class EnterpriseReport(ABC): ODATA_FEEDS = 'odata_feeds' COMMCARE_VERSION_COMPLIANCE = 'commcare_version_compliance' SMS = 'sms' + API_USAGE = 'api_usage' DATE_ROW_FORMAT = '%Y/%m/%d %H:%M:%S' @@ -94,7 +96,8 @@ def create(cls, slug, account_id, couch_user, **kwargs): report = EnterpriseCommCareVersionReport(account, couch_user, **kwargs) elif slug == cls.SMS: report = EnterpriseSMSReport(account, couch_user, **kwargs) - + elif slug == cls.API_USAGE: + report = EnterpriseAPIReport(account, couch_user, **kwargs) if report: report.slug = slug return report @@ -561,3 +564,36 @@ def rows_for_domain(self, domain_obj): num_errors = sum([result['direction_count'] for result in results if result['error']]) return [(domain_obj.name, num_sent, num_received, num_errors), ] + + +class EnterpriseAPIReport(EnterpriseReport): + title = gettext_lazy('API Usage') + total_description = gettext_lazy('# of Active API Keys') + + @property + def headers(self): + return [_('Web User'), _('API Key Name'), _('Scope'), _('Expiration Date [UTC]'), _('Created On [UTC]'), + _('Last Used On [UTC]')] + + @property + def rows(self): + return [self._get_api_key_row(api_key) for api_key in self.unique_api_keys()] + + @property + def total(self): + return self.unique_api_keys().count() + + def unique_api_keys(self): + usernames = self.account.get_web_user_usernames() + unique_users = [User.objects.get(username=username) for username in usernames] + return HQApiKey.objects.filter(user__in=unique_users, is_active=True) + + def _get_api_key_row(self, api_key): + return [ + api_key.user.username, + api_key.name, + api_key.domain if api_key.domain else 'All project spaces', + self.format_date(api_key.expiration_date), + self.format_date(api_key.created), + self.format_date(api_key.last_used), + ] diff --git a/corehq/apps/enterprise/views.py b/corehq/apps/enterprise/views.py index e6fa05b4b719..861edf12b6d5 100644 --- a/corehq/apps/enterprise/views.py +++ b/corehq/apps/enterprise/views.py @@ -123,8 +123,12 @@ def security_center(request, domain): ) context.update({ - 'reports': [], + 'reports': [EnterpriseReport.create(slug, request.account.id, request.couch_user) for slug in ( + EnterpriseReport.API_USAGE, + )], 'metric_type': 'Security Center', + 'max_date_range_days': EnterpriseFormReport.MAX_DATE_RANGE_DAYS, + 'uses_date_range': [], }) return render(request, "enterprise/project_dashboard.html", context) From f19f02099742e3ec389af98bffcf695ce7926f7e Mon Sep 17 00:00:00 2001 From: Jing Cheng Date: Tue, 17 Dec 2024 14:09:16 -0500 Subject: [PATCH 2/5] add Api Usage api --- corehq/apps/enterprise/api/api.py | 2 ++ corehq/apps/enterprise/api/resources.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/corehq/apps/enterprise/api/api.py b/corehq/apps/enterprise/api/api.py index 3b98462511c1..34f2bba1cd59 100644 --- a/corehq/apps/enterprise/api/api.py +++ b/corehq/apps/enterprise/api/api.py @@ -8,6 +8,7 @@ ODataFeedResource, WebUserResource, SMSResource, + APIUsageResource, ) v1_api = Api(api_name='v1') @@ -18,3 +19,4 @@ v1_api.register(ODataFeedResource()) v1_api.register(CommCareVersionComplianceResource()) v1_api.register(SMSResource()) +v1_api.register(APIUsageResource()) diff --git a/corehq/apps/enterprise/api/resources.py b/corehq/apps/enterprise/api/resources.py index aebf6ac5563d..3b7740ca4ea5 100644 --- a/corehq/apps/enterprise/api/resources.py +++ b/corehq/apps/enterprise/api/resources.py @@ -425,3 +425,26 @@ def dehydrate(self, bundle): def get_primary_keys(self): return ('mobile_worker', 'project_space',) + + +class APIUsageResource(ODataEnterpriseReportResource): + web_user = fields.CharField() + api_key_name = fields.CharField() + scope = fields.CharField() + expiration_date = fields.DateTimeField() + created_date = fields.DateTimeField() + last_used_date = fields.DateTimeField() + + REPORT_SLUG = EnterpriseReport.API_USAGE + + def dehydrate(self, bundle): + bundle.data['web_user'] = bundle.obj[0] + bundle.data['api_key_name'] = bundle.obj[1] + bundle.data['scope'] = bundle.obj[2] + bundle.data['expiration_date'] = self.convert_datetime(bundle.obj[3]) + bundle.data['created_date'] = self.convert_datetime(bundle.obj[4]) + bundle.data['last_used_date'] = self.convert_datetime(bundle.obj[5]) + return bundle + + def get_primary_keys(self): + return ('web_user', 'api_key_name',) From 89913e514937471b1b77b7e2ea108d1627157e4f Mon Sep 17 00:00:00 2001 From: Jing Cheng Date: Wed, 18 Dec 2024 15:10:34 -0500 Subject: [PATCH 3/5] Simplify the query and use Subquery --- corehq/apps/enterprise/enterprise.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/corehq/apps/enterprise/enterprise.py b/corehq/apps/enterprise/enterprise.py index 9b79366d99e5..327fb72af5b8 100644 --- a/corehq/apps/enterprise/enterprise.py +++ b/corehq/apps/enterprise/enterprise.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod import re -from django.db.models import Count from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth.models import User +from django.db.models import Count, Subquery from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy @@ -585,8 +585,8 @@ def total(self): def unique_api_keys(self): usernames = self.account.get_web_user_usernames() - unique_users = [User.objects.get(username=username) for username in usernames] - return HQApiKey.objects.filter(user__in=unique_users, is_active=True) + user_ids = User.objects.filter(username__in=usernames).values_list('id', flat=True) + return HQApiKey.objects.filter(user_id__in=Subquery(user_ids), is_active=True) def _get_api_key_row(self, api_key): return [ From fa5ea7c24a6b1bce6c0b857d112f7bb817fba834 Mon Sep 17 00:00:00 2001 From: Jing Cheng Date: Wed, 18 Dec 2024 15:17:28 -0500 Subject: [PATCH 4/5] Exclude api key whose scope is not to any project spaces in this billing account --- corehq/apps/enterprise/enterprise.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/corehq/apps/enterprise/enterprise.py b/corehq/apps/enterprise/enterprise.py index 327fb72af5b8..484bfeb43f6e 100644 --- a/corehq/apps/enterprise/enterprise.py +++ b/corehq/apps/enterprise/enterprise.py @@ -4,7 +4,7 @@ from django.conf import settings from django.contrib.auth.models import User -from django.db.models import Count, Subquery +from django.db.models import Count, Subquery, Q from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy @@ -586,7 +586,13 @@ def total(self): def unique_api_keys(self): usernames = self.account.get_web_user_usernames() user_ids = User.objects.filter(username__in=usernames).values_list('id', flat=True) - return HQApiKey.objects.filter(user_id__in=Subquery(user_ids), is_active=True) + + return HQApiKey.objects.filter( + user_id__in=Subquery(user_ids), + is_active=True + ).filter( + Q(domain__in=self.domains) | Q(domain='') + ) def _get_api_key_row(self, api_key): return [ From 95edba1e70b4ca750a2d388fc6aeb56723b4222f Mon Sep 17 00:00:00 2001 From: Jing Cheng Date: Wed, 18 Dec 2024 15:38:33 -0500 Subject: [PATCH 5/5] Reformat the report row Although some api key's scope is all project spaces, but we should only show project space that is a member of the billing account and the user is a member of, which reflects the actual scope --- corehq/apps/enterprise/enterprise.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/corehq/apps/enterprise/enterprise.py b/corehq/apps/enterprise/enterprise.py index 484bfeb43f6e..b196a4d64e10 100644 --- a/corehq/apps/enterprise/enterprise.py +++ b/corehq/apps/enterprise/enterprise.py @@ -37,7 +37,7 @@ get_mobile_user_count, get_web_user_count, ) -from corehq.apps.users.models import CouchUser, HQApiKey, Invitation +from corehq.apps.users.models import CouchUser, HQApiKey, Invitation, WebUser class EnterpriseReport(ABC): @@ -586,19 +586,28 @@ def total(self): def unique_api_keys(self): usernames = self.account.get_web_user_usernames() user_ids = User.objects.filter(username__in=usernames).values_list('id', flat=True) + domains = self.account.get_domains() return HQApiKey.objects.filter( user_id__in=Subquery(user_ids), is_active=True ).filter( - Q(domain__in=self.domains) | Q(domain='') + Q(domain__in=domains) | Q(domain='') ) def _get_api_key_row(self, api_key): + if api_key.domain: + scope = api_key.domain + else: + user_domains = set(WebUser.get_by_username(api_key.user.username).get_domains()) + account_domains = set(self.account.get_domains()) + intersected_domains = user_domains.intersection(account_domains) + scope = ', '.join((intersected_domains)) + return [ api_key.user.username, api_key.name, - api_key.domain if api_key.domain else 'All project spaces', + scope, self.format_date(api_key.expiration_date), self.format_date(api_key.created), self.format_date(api_key.last_used),