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

Capture Payment Phone numbers #50

Merged
merged 10 commits into from
Dec 12, 2024
Merged

Capture Payment Phone numbers #50

merged 10 commits into from
Dec 12, 2024

Conversation

sravfeyn
Copy link
Member

@sravfeyn sravfeyn commented Nov 7, 2024

This implements connect-id side endpoints for capturing users' mobile payment phone numbers. I have tested these locally using curl

Spec

@sravfeyn sravfeyn requested a review from calellowitz November 7, 2024 15:47
@sravfeyn
Copy link
Member Author

Corresponding connect PR dimagi/commcare-connect#428

@sravfeyn
Copy link
Member Author

@calellowitz Please have a review of this and the corresponding connect PR that's linked. It will make it easy for mobile to test these.

Copy link
Collaborator

@calellowitz calellowitz left a comment

Choose a reason for hiding this comment

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

Left a few questions and a few suggestions

telecom_provider = models.CharField(max_length=50, blank=True, null=True)
# whether the number is verified using OTP
is_verified = models.BooleanField(default=False)
status = models.CharField(
Copy link
Collaborator

Choose a reason for hiding this comment

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

what does this column do?

Copy link
Member Author

Choose a reason for hiding this comment

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

This tracks whether this is pending for review or approved or rejected by connect user.

utils/twilio.py Outdated
from django.conf import settings

# Create the client instance only once
twilio_client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the reason to have this be a singleton?

Copy link
Member Author

Choose a reason for hiding this comment

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

When you instantiate the client, there's bunch of auth overhead API calls. Singleton would avoid that overhead each time we need it

Copy link
Collaborator

@calellowitz calellowitz Nov 22, 2024

Choose a reason for hiding this comment

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

there's bunch of auth overhead API calls

Where do you see this? I looked at the code and don't see any calls on instantiation, and that would be odd behavior, since auth need to be per request anyway.

Copy link
Member Author

@sravfeyn sravfeyn Nov 22, 2024

Choose a reason for hiding this comment

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

Thanks for looking that up, it looks like there is no overhead, I thought I read this somewhere but I was wrong. I have removed singleton

try:
phone_info = client.lookups.v1.phone_numbers(phone_number).fetch(type="carrier")
return phone_info.carrier.get("name")
except Exception as e:
Copy link
Collaborator

Choose a reason for hiding this comment

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

This shouldn't catch bare exceptions. Is there a specific error you are looking for?

Copy link
Member Author

@sravfeyn sravfeyn Nov 14, 2024

Choose a reason for hiding this comment

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

That's true, I will adjust it such it logs the exception if any. For now, I will catch all exceptions.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Why catch the exceptions and return instead of just letting the exceptions fail loudly? Especially since that will allow them to be picked up by sentry or similar error logging (once that is fully set up). Letting the exception happen also results in it being logged, but would also let the mobile know that it failed.

Copy link
Member Author

Choose a reason for hiding this comment

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

My reasoning is that hard failing would block user from giving us the payment number at all. If we capture and log the exception and return empty telecom-provider, user's number would still be captured. We can look at the logs and retroactively update the telecom provider after dealing with the exception as we roll out this feature.

If we hardfail, we would have missed user's input and even if we deal with the exception latter, there is no way to go ask user again for payment number. They will have to know that the issue is fixed and then retry.

Though, I agree that this means the partial loss of data integrity. I am hoping to keep an eye on the logs initially, deal with them as they come up and then implement hardfailure latter.

'status': PaymentProfile.PENDING
}
)
return PhoneDevice.send_otp_httpresponse(phone_number=payment_profile.phone_number, user=payment_profile.user)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We want to send this every time they hit here or only on initial creation (and maybe if the phone number changes)?

Copy link
Member Author

Choose a reason for hiding this comment

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

We have this single view to manage resend func, so we want to send it each time this view is called.

from users.models import ConnectUser


class PaymentProfile(models.Model):
Copy link
Collaborator

Choose a reason for hiding this comment

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

If there can only be one of these per user, what is the reason to have a separate model rather than adding these columns to the existing model (and then avoiding the extra lookups and possible exceptions).

Copy link
Member Author

Choose a reason for hiding this comment

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

There are too many fields to add on user directly (telecom_provider, otp-verification status, payment validation status).

return JsonResponse({"success": True})


class FetchPhoneNumbers(APIView):
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: these views should include the "impossible" scopes to prevent access tokens from working. I think it isn't necessary but am not sure enough to avoid adding it for safety.

Copy link
Member Author

Choose a reason for hiding this comment

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

What do you mean by "impossible" scopes?

phone_number = data["phone_number"]
status = data["status"]

filter_conditions |= Q(user__username=username, phone_number=phone_number)
Copy link
Collaborator

Choose a reason for hiding this comment

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

since payment profiles are one to one with username why does the phone number need to be in the query? Can't it just be user__usernames__in=<list from request>, and similarly the dict can key just off the username?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I guess, we could simplify it. Though, one (debatable) advantage of this is that it would guard against the hypothetical scenario of two users using same payment phone number.

Copy link
Collaborator

Choose a reason for hiding this comment

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

How does it guard against that? If we fetch by username, we are guaranteed to get the right number regardless of if it is shared.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, I have updated it and corresponding api call on connect side to remove phone-numbers


profiles = PaymentProfile.objects.filter(filter_conditions).select_related("user")
if len(profiles) != len(users_data):
return Response(status=drf_status.HTTP_404_NOT_FOUND)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This throws a 404 if any aren't found? It might be nicer to just return the success list at the end so one bad entry doesn't ruin the whole request.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ideally, this shouldn't happen since the request is coming from the view which returns valid profiles in the first place. So, it's not worth partially succeeding and then managing partial failure that calls this view.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ideally, this shouldn't happen

I agree it shouldn't happen, but if it does, as this code implies might happen, is there a reason to fail the whole request, rather than succeed partially? Especially since the users don't choose which numbers are sent (connect does it automatically).

Copy link
Member Author

Choose a reason for hiding this comment

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

Let me clarify the flow here:

The profiles in this request come from a UI where users select from valid phone numbers that we've already verified exist. The only way this 404 could trigger is if someone manually manipulates the POST parameters.

Given this is an authenticated view and parameters are user-selected from validated options, failing fast on invalid input serves as a security boundary - it indicates either request tampering or data integrity issues that we'd want to catch and log rather than partially process. This is different from cases where users don't control the input parameters.

Does this help explain the reasoning behind?

}
with transaction.atomic():
for profile in profiles:
key = (profile.user.username, profile.phone_number)
Copy link
Collaborator

Choose a reason for hiding this comment

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

same question about just using username

"approved": [],
"rejected": [],
}
with transaction.atomic():
Copy link
Collaborator

Choose a reason for hiding this comment

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

what is this guarding against? It only wraps the update at the end, so I am not sure how it could end up rolled back in an unfinished state.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think I had it when I did it sequentially instead of bulk update, I will remove it.

@sravfeyn
Copy link
Member Author

@calellowitz please have a look again.

Copy link
Collaborator

@calellowitz calellowitz left a comment

Choose a reason for hiding this comment

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

The changes look good, but I am still curious about some of my previous questions

'phone_number': phone_number,
'telecom_provider': telecom_provider,
'is_verified': False,
'status': PaymentProfile.PENDING
Copy link
Collaborator

Choose a reason for hiding this comment

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

If the number changes, do we not want to always reset that status value?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we are re-setting to PENDING whenever the payment data is updated.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah sorry, I was confused about the behavior of the defaults dict.

try:
phone_info = client.lookups.v1.phone_numbers(phone_number).fetch(type="carrier")
return phone_info.carrier.get("name")
except Exception as e:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why catch the exceptions and return instead of just letting the exceptions fail loudly? Especially since that will allow them to be picked up by sentry or similar error logging (once that is fully set up). Letting the exception happen also results in it being logged, but would also let the mobile know that it failed.

phone_info = client.lookups.v1.phone_numbers(phone_number).fetch(type="carrier")
return phone_info.carrier.get("name")
except Exception as e:
logger.exception("Error occurred during Twilio call for phone number %s: %s", phone_number, str(e))

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (private)
as clear text.
This expression logs
sensitive data (private)
as clear text.

Copilot Autofix AI 25 days ago

To fix the problem, we should avoid logging the sensitive phone_number directly. Instead, we can log a masked version of the phone number or omit it entirely from the log message. This way, we still retain useful information for debugging purposes without exposing sensitive data.

The best way to fix this issue is to mask the phone number before logging it. We can replace the middle digits of the phone number with asterisks or another placeholder character. This approach ensures that the phone number is not exposed in clear text while still providing enough information to identify the general format of the number.

We need to modify the logging statement on line 16 to mask the phone number before logging it.

Suggested changeset 1
utils/twilio.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/utils/twilio.py b/utils/twilio.py
--- a/utils/twilio.py
+++ b/utils/twilio.py
@@ -15,3 +15,4 @@
     except Exception as e:
-        logger.exception("Error occurred during Twilio call for phone number %s: %s", phone_number, str(e))
+        masked_phone_number = phone_number[:2] + "****" + phone_number[-2:]
+        logger.exception("Error occurred during Twilio call for phone number %s: %s", masked_phone_number, str(e))
         return None
EOF
@@ -15,3 +15,4 @@
except Exception as e:
logger.exception("Error occurred during Twilio call for phone number %s: %s", phone_number, str(e))
masked_phone_number = phone_number[:2] + "****" + phone_number[-2:]
logger.exception("Error occurred during Twilio call for phone number %s: %s", masked_phone_number, str(e))
return None
Copilot is powered by AI and may make mistakes. Always verify output.
Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options
Copy link
Member Author

Choose a reason for hiding this comment

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

The number is getting intentionally logged to see which numbers are failing

@calellowitz
Copy link
Collaborator

Still not thrilled with the swallowed errors, but this looks fine once the conflicts are fixed

@sravfeyn sravfeyn merged commit 161f4c7 into main Dec 12, 2024
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants