Skip to content

Commit

Permalink
Merge pull request #34151 from dimagi/jc/auto-deactivate-removed-sso-…
Browse files Browse the repository at this point in the history
…users

Task to auto deactivate removed sso users
  • Loading branch information
jingcheng16 authored Apr 24, 2024
2 parents ea1f236 + 8871bad commit b88782e
Show file tree
Hide file tree
Showing 16 changed files with 456 additions and 15 deletions.
17 changes: 17 additions & 0 deletions corehq/apps/accounting/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from corehq.apps.accounting.utils.stripe import charge_through_stripe

from corehq.apps.domain.shortcuts import publish_domain_saved
from corehq.apps.users.dbaccessors import get_active_web_usernames_by_domain, get_web_user_count
from dimagi.ext.couchdbkit import (
BooleanProperty,
DateTimeProperty,
Expand Down Expand Up @@ -603,6 +604,22 @@ def _send_autopay_card_added_email(self, domain):
use_domain_gateway=True,
)

def get_web_user_usernames(self):
domains = self.get_domains()
web_users = set()

for domain in domains:
web_users.update(get_active_web_usernames_by_domain(domain))

return web_users

def get_web_user_count(self):
domains = self.get_domains()
count = 0
for domain in domains:
count += get_web_user_count(domain, include_inactive=False)
return count

@staticmethod
def should_show_sms_billable_report(domain):
account = BillingAccount.get_account_by_domain(domain)
Expand Down
21 changes: 8 additions & 13 deletions corehq/apps/accounting/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@
from corehq.apps.accounting.utils.subscription import (
assign_explicit_unpaid_subscription,
)
from corehq.apps.users.models import WebUser
from corehq.apps.app_manager.dbaccessors import get_all_apps
from corehq.apps.celery import periodic_task, task
from corehq.apps.domain.models import Domain
Expand Down Expand Up @@ -554,13 +553,13 @@ def weekly_digest():
in_forty_days = today + datetime.timedelta(days=40)

ending_in_forty_days = [sub for sub in Subscription.visible_objects.filter(
date_end__lte=in_forty_days,
date_end__gte=today,
is_active=True,
is_trial=False,
).exclude(
account__dimagi_contact='',
) if not sub.is_renewed]
date_end__lte=in_forty_days,
date_end__gte=today,
is_active=True,
is_trial=False,
).exclude(
account__dimagi_contact='',
) if not sub.is_renewed]

if not ending_in_forty_days:
log_accounting_info(
Expand Down Expand Up @@ -833,11 +832,7 @@ def calculate_web_users_in_all_billing_accounts(today=None):
today = today or datetime.date.today()
for account in BillingAccount.objects.all():
record_date = today - relativedelta(days=1)
domains = account.get_domains()
web_user_in_account = set()
for domain in domains:
[web_user_in_account.add(id) for id in WebUser.ids_by_domain(domain)]
num_users = len(web_user_in_account)
num_users = account.get_web_user_count()
try:
BillingAccountWebUserHistory.objects.create(
billing_account=account,
Expand Down
10 changes: 10 additions & 0 deletions corehq/apps/sso/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ def __init__(self, error_code, message=None):
super().__init__()
self.code = error_code
self.message = message


class EntraVerificationFailed(Exception):
def __init__(self, error, message):
super().__init__(f"{error}: {message}")
self.error = error
self.message = message

def __str__(self):
return f"EntraVerificationFailed({self.error}, {self.message})"
11 changes: 11 additions & 0 deletions corehq/apps/sso/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from django.db import models
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
Expand All @@ -7,9 +9,12 @@
from corehq.apps.accounting.models import BillingAccount, Subscription
from corehq.apps.sso import certificates
from corehq.apps.sso.exceptions import ServiceProviderCertificateError
from corehq.apps.sso.utils.entra import get_all_members_of_the_idp_from_entra
from corehq.apps.sso.utils.user_helpers import get_email_domain_from_username
from corehq.util.quickcache import quickcache

log = logging.getLogger(__name__)


class IdentityProviderType:
ENTRA_ID = 'azure_ad' # Microsoft renamed Entra ID to Azure ID after implementing this feature
Expand Down Expand Up @@ -434,6 +439,12 @@ def get_required_identity_provider(cls, username):
return idp
return None

def get_all_members_of_the_idp(self):
if self.idp_type == IdentityProviderType.ENTRA_ID:
return get_all_members_of_the_idp_from_entra(self)
else:
raise NotImplementedError("Not implemented")


@receiver(post_save, sender=Subscription)
@receiver(post_delete, sender=Subscription)
Expand Down
98 changes: 97 additions & 1 deletion corehq/apps/sso/tasks.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
import datetime
import logging
import requests

from celery.schedules import crontab

from corehq.apps.celery import periodic_task, task
from corehq.apps.hqwebapp.tasks import send_html_email_async
from corehq.apps.sso.models import IdentityProvider, IdentityProviderProtocol
from corehq.apps.sso.exceptions import EntraVerificationFailed
from corehq.apps.sso.models import (
AuthenticatedEmailDomain,
IdentityProvider,
IdentityProviderProtocol,
IdentityProviderType,
UserExemptFromSingleSignOn
)
from corehq.apps.sso.utils.context_helpers import (
get_api_secret_expiration_email_context,
get_idp_cert_expiration_email_context,
get_sso_deactivation_skip_email_context,
)
from corehq.apps.sso.utils.entra import MSGraphIssue
from corehq.apps.sso.utils.user_helpers import get_email_domain_from_username
from corehq.apps.users.models import WebUser
from corehq.apps.users.models import HQApiKey
from django.contrib.auth.models import User
from corehq.sql_db.util import paginate_query
from django.db import router
from django.db.models import Q
from django.utils.translation import gettext as _
from dimagi.utils.chunked import chunked
from dimagi.utils.logging import notify_exception

Expand Down Expand Up @@ -110,6 +123,89 @@ def send_idp_cert_expires_reminder_emails(num_days):
)


@periodic_task(run_every=crontab(minute=0, hour=2), acks_late=True)
def auto_deactivate_removed_sso_users():
for idp in IdentityProvider.objects.filter(
enable_user_deactivation=True,
idp_type=IdentityProviderType.ENTRA_ID
).all():
try:
idp_users = idp.get_all_members_of_the_idp()
except EntraVerificationFailed as e:
notify_exception(None, f"Failed to get members of the IdP. {str(e)}")
send_deactivation_skipped_email(idp=idp, failure_code=MSGraphIssue.VERIFICATION_ERROR,
error=EntraVerificationFailed.error,
error_description=EntraVerificationFailed.message)
continue
except requests.exceptions.HTTPError as e:
notify_exception(None, f"Failed to get members of the IdP. {str(e)}")
send_deactivation_skipped_email(idp=idp, failure_code=MSGraphIssue.HTTP_ERROR)
continue
except Exception as e:
notify_exception(None, f"Failed to get members of the IdP. {str(e)}")
send_deactivation_skipped_email(idp=idp, failure_code=MSGraphIssue.OTHER_ERROR)
continue

# if the Graph Users API returns an empty list of users we will skip auto deactivation
if len(idp_users) == 0:
send_deactivation_skipped_email(idp=idp, failure_code=MSGraphIssue.EMPTY_ERROR)
continue

usernames_in_account = idp.owner.get_web_user_usernames()

# Get criteria for exempting usernames and email domains from the deactivation list
authenticated_domains = AuthenticatedEmailDomain.objects.filter(identity_provider=idp)
exempt_usernames = UserExemptFromSingleSignOn.objects.filter(email_domain__in=authenticated_domains
).values_list('username', flat=True)

usernames_to_deactivate = []
authenticated_email_domains = authenticated_domains.values_list('email_domain', flat=True)

for username in usernames_in_account:
if username not in idp_users and username not in exempt_usernames:
email_domain = get_email_domain_from_username(username)
if email_domain in authenticated_email_domains:
usernames_to_deactivate.append(username)

# Deactivate user that is not returned by Graph Users API
for username in usernames_to_deactivate:
user = WebUser.get_by_username(username)
if user and user.is_active:
user.is_active = False
user.save()


def send_deactivation_skipped_email(idp, failure_code, error=None, error_description=None):
if failure_code == MSGraphIssue.VERIFICATION_ERROR:
failure_reason = _("There was an issue connecting to the Microsoft Graph API. "
f"Error: {error}. Error description: {error_description}")
elif failure_code == MSGraphIssue.HTTP_ERROR:
failure_reason = _("An HTTP error occured when connecting to the Microsoft Graph API, which usually"
"indicates an issue with Microsoft's servers.")
elif failure_code == MSGraphIssue.EMPTY_ERROR:
failure_reason = _("We received an empty list of users from your Microsoft Entra ID instance.")
elif failure_code == MSGraphIssue.OTHER_ERROR:
failure_reason = _("We encountered an unknown issue, please contact Commcare HQ Support.")

context = get_sso_deactivation_skip_email_context(idp, failure_reason)
for send_to in context["to"]:
send_html_email_async.delay(
context["subject"],
send_to,
context["html"],
text_content=context["plaintext"],
email_from=context["from"],
bcc=context["bcc"],
)
log.info(
"Sent sso user deactivation skipped notification"
"email for %(idp_name)s to %(send_to)s." % {
"idp_name": idp.name,
"send_to": send_to,
}
)


@task(bind=True, default_retry_delay=15 * 60, max_retries=10, acks_late=True)
def update_sso_user_api_key_expiration_dates(self, identity_provider_id):
idp = IdentityProvider.objects.get(id=identity_provider_id)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{% load i18n %}
<p>{% blocktrans %}Dear enterprise administrator,{% endblocktrans %}</p>

<p>
{% blocktrans %}
We have temporarily skipped automatic deactivation of SSO-managed Web Users for the following reason:
{{ failure_reason }}
{% endblocktrans %}
</p>

<p>
{% blocktrans %}
Please check the configuration for Remote User Management in your Identity Provider settings.
{% endblocktrans %}
</p>

<p>
{% blocktrans %}
If you have any questions, or if this issue persists, please don’t hesitate to contact
{{ contact_email }}. Thank you for your use and support of CommCare.
{% endblocktrans %}
</p>


<p>
{% blocktrans %}
Best regards,
{% endblocktrans %}
</p>

<p>
{% blocktrans %}The CommCare HQ Team{% endblocktrans %}<br />
{{ base_url }}
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% load i18n %}
{% blocktrans %}
Dear enterprise administrator,

We have temporarily skipped automatic deactivation of SSO-managed Web Users for the following reason:
{{ failure_reason }}

Please check the configuration for Remote User Management in your Identity Provider settings.

If you have any questions, or if this issue persists, please don’t hesitate to contact {{ contact_email }}.
Thank you for your use and support of CommCare.

Best regards,

The CommCare HQ Team
{{ base_url }}
{% endblocktrans %}
Loading

0 comments on commit b88782e

Please sign in to comment.