Skip to content

chore(users): invite email hardening — throttle, dedupe, lowercase (#173)#181

Open
b3lz3but wants to merge 2 commits intocaptainpragmatic:masterfrom
b3lz3but:chore/invite-email-hardening
Open

chore(users): invite email hardening — throttle, dedupe, lowercase (#173)#181
b3lz3but wants to merge 2 commits intocaptainpragmatic:masterfrom
b3lz3but:chore/invite-email-hardening

Conversation

@b3lz3but
Copy link
Copy Markdown
Contributor

@b3lz3but b3lz3but commented Apr 16, 2026

Summary

  • Rate limiting: added @throttle_classes([BurstAPIThrottle]) to customer_users_create endpoint. A compromised portal token could previously create users in a loop triggering unbounded email sending.
  • Deduplication: extracted send_welcome_email() as a module-level function. Both SecureUserRegistrationService._send_welcome_email_secure and SecureCustomerUserService._send_welcome_email_secure now delegate to this single source of truth. A bug fix to one now automatically applies to both.
  • Empty company_name: email subject now falls back to customer.name or "your organization" instead of producing "Account Created for ".
  • Lowercase email: both the API and staff views now .lower() the email before DB lookup, so User@Example.com hits the exists check cleanly instead of passing through to an IntegrityError.

Not included (deferred)

Test plan

  • 17 targeted customer/API tests pass
  • mypy clean on all 3 modified files

Addresses #173

🤖 Generated with Claude Code

Closes #173

…aptainpragmatic#173)

- Add @throttle_classes([BurstAPIThrottle]) to customer_users_create API
  endpoint so a compromised portal token cannot create users in a loop
- Extract send_welcome_email() as a module-level function (single source
  of truth), replacing byte-for-byte identical _send_welcome_email_secure
  methods on both service classes with thin delegates
- Fix empty company_name producing broken email subject by falling back
  to customer.name or "your organization"
- Lowercase email before DB lookup in both the API and staff views so
  case-mismatched duplicates get a clean error instead of IntegrityError

Addresses captainpragmatic#173 (HIGH + MEDIUM + LOW items).

Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
@b3lz3but
Copy link
Copy Markdown
Contributor Author

@mostlyvirtual CI green — rate limiting, deduplication, company_name fallback, and email lowercasing all addressed. Ready for review 🙏

Copy link
Copy Markdown
Contributor

@mostlyvirtual mostlyvirtual left a comment

Choose a reason for hiding this comment

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

Review — Request changes

The dedupe extraction reads cleanly and the lowercase normalization aligns with the patterns in users/forms.py. Two blocking issues need a second pass before this is ready to merge.

Blocker 1 — The throttle change is a regression on this endpoint

@throttle_classes([BurstAPIThrottle]) on customer_users_create (services/platform/apps/api/customers/views.py:981) replaces (not augments) DEFAULT_THROTTLE_CLASSES. DRF's per-view throttle_classes is an override, so this endpoint loses the existing HMAC-aware PortalHMACRateThrottle / PortalHMACBurstThrottle keyed by portal id (apps/api/core/rate_limiting.py:172,185; portal id set in apps/api/core/middleware.py:571-578).

What we end up with is weaker, not stronger, protection:

  • BurstAPIThrottle extends UserRateThrottle (rate_limiting.py:261) which keys on request.user
  • This view sets authentication_classes([]), so request.user is AnonymousUser
  • UserRateThrottle.get_cache_key() then falls back to client IP
  • A compromised portal token routed through multiple IPs effectively bypasses this throttle, while legitimate portal traffic behind one egress IP gets pooled
  • api_burst is 120/min vs AuthThrottle at 10/min — for an account-creation mutation, 120/min is far too permissive

Suggested fix: keep the HMAC throttles in place and add a stricter mutation throttle on top — e.g., a scoped throttle keyed by verified portal id. If the goal is just "stricter than the burst default for this endpoint," AuthThrottle is the closest existing match (already imported at views.py:21, already used by customer_register_api).

Blocker 2 — customer_users_create builds the shared helper but never calls it

The new module-level send_welcome_email() (apps/users/services.py) is the right shape, and SecureCustomerUserService._send_welcome_email_secure correctly delegates to it. But customer_users_create — the endpoint this PR is hardening — never invokes send_welcome_email. The user is created with an unusable password (line 1015) and no activation path. The inline comment on lines 1017-1018 says "they must complete account setup via the password-reset link sent separately" but no code in this PR or in the existing flow sends that link.

test_create_user_success (test_customer_api.py:148) confirms the gap: it asserts has_usable_password() is False but never asserts a welcome email was dispatched.

Suggested fix: after the with transaction.atomic() block in customer_users_create, call send_welcome_email(new_user, customer, request_ip=None). Without this the dedupe work produces a clean shared helper that the endpoint it's meant to support doesn't actually use.

Smaller items (non-blocking but worth fixing in the same revision)

  • Subject-line drift in the dedupe (services.py:125-137): the old _send_welcome_email_secure body used customer.company_name; the new shared function falls back to customer.name / "your organization". May be intentional but it's not pure dedupe — please confirm it's deliberate.
  • Lowercase vs normalize_email(): views.py:995 and apps/customers/user_management_views.py:94 use full .lower(). UserManager.create_user() uses Django normalize_email() (apps/users/models.py:39), which only lowercases the domain. In the current paths there's no divergence because create_user receives the already-lowercased value, but pre-existing mixed-case rows on a case-sensitive DB will not be caught by the duplicate check. Either commit to email__iexact for the dedupe lookup, or make the policy explicit in a docstring.
  • Dead code: SecureUserRegistrationService._send_welcome_email_secure no longer has internal callers (only SecureCustomerUserService still uses its variant). Safe to remove.
  • Staff form path TOCTOU (user_management_views.py:109-118): no atomic() wrapper and no IntegrityError handler — concurrent submits will 500. Out of scope for this PR but tracking it for a follow-up.

Tests

The three stated changes each lack a regression guard:

  1. No test asserts the throttle class identity on the view (would not fail if the decorator were removed)
  2. No mixed-case duplicate test (e.g., NewUser@EXAMPLE.COM vs existing newuser@example.com) — the lowercase change has no test that proves it works
  3. No assertion that the welcome email is dispatched on a successful invite

The ThrottleArchitectureGuardrailTests suite has a pattern (inspecting view.throttle_classes) that can be reused for #1.

Verdict

Rework. Once the throttle composition is fixed and send_welcome_email is called from the invite endpoint with regression tests covering all three changes, this should be straightforward to land.

…lcome email, iexact dedupe

Resolves blockers from @mostlyvirtual review on 2026-05-05:

- Replace BurstAPIThrottle with AuthThrottle on customer_users_create.
  BurstAPIThrottle on an authentication_classes([]) view falls back to IP
  keying at 120/min and overrides the HMAC-aware portal throttles. AuthThrottle
  (10/min, scope=auth) is the correct strict mutation throttle and layers on
  top of the global HMAC throttles.
- Call send_welcome_email() from customer_users_create after the atomic block
  so the new user actually receives a password-reset link to complete account
  setup. Previously the helper was extracted but never invoked from the
  endpoint it was meant to support.
- Switch dedupe lookups to email__iexact in both API and staff form paths to
  catch mixed-case rows on case-sensitive DBs where pre-existing data may not
  have been lowercased at write time.
- Remove dead SecureUserRegistrationService._send_welcome_email_secure (no
  internal callers; SecureCustomerUserService keeps its variant).
- Document the company_name -> name -> static fallback in the helper docstring
  so the subject-line behavior for individual customers is explicit.

New regression tests:
- test_create_user_uses_auth_throttle: asserts throttle class identity
- test_create_user_mixed_case_duplicate_returns_400: covers iexact lookup
- test_create_user_dispatches_welcome_email: asserts send_welcome_email call

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@b3lz3but
Copy link
Copy Markdown
Contributor Author

b3lz3but commented May 6, 2026

@mostlyvirtual addressed both blockers + smaller items in 7b315b7.

Blocker 1 — Throttle: BurstAPIThrottleAuthThrottle (10/min, scope=auth). Layers on top of global HMAC throttles instead of overriding them.

Blocker 2 — Helper now called: send_welcome_email(new_user, customer, request_ip=None) invoked after the atomic block in customer_users_create.

Smaller items:

  • email__iexact lookup in both API and staff form paths
  • Dead SecureUserRegistrationService._send_welcome_email_secure removed
  • Subject-line fallback (company_namename → static) documented in helper docstring as deliberate (covers individual customers)

Regression tests added (per the ThrottleArchitectureGuardrailTests pattern you suggested):

  • test_create_user_uses_auth_throttle — throttle class identity
  • test_create_user_mixed_case_duplicate_returns_400NewUser@EXAMPLE.COM vs newuser@example.com
  • test_create_user_dispatches_welcome_email — asserts dispatch on success

50/50 affected tests pass, ruff + mypy clean. Staff form path TOCTOU left for follow-up as noted. Ready for second pass 🙏

@b3lz3but
Copy link
Copy Markdown
Contributor Author

b3lz3but commented May 7, 2026

@mostlyvirtual ready for re-review. The latest commit (2026-05-06) addresses both blockers and the smaller items:

Blocker 1 — throttle composition: customer_users_create now uses AuthThrottle (10/min, scope=auth) instead of BurstAPIThrottle. AuthThrottle layers on top of the global HMAC throttles rather than overriding them via per-view scope, and the 10/min ceiling is appropriate for an account-creation mutation. New test test_create_user_uses_auth_throttle asserts the throttle class identity (using the ThrottleArchitectureGuardrailTests pattern you flagged).

Blocker 2 — send_welcome_email now invoked: The endpoint now calls send_welcome_email(new_user, customer, request_ip=None) after the atomic() block, so the new user receives the password-reset link to complete setup. New test test_create_user_dispatches_welcome_email asserts dispatch.

Smaller items:

  • Subject-line fallback (company_name → name → "your organization") documented in the helper docstring.
  • Both API and staff form dedupe paths switched to email__iexact. New test test_create_user_mixed_case_duplicate_returns_400 covers the regression.
  • Dead SecureUserRegistrationService._send_welcome_email_secure removed.
  • Staff form TOCTOU left for follow-up as noted.

Three new tests cover all three regression guards you called out.

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.

chore: invite email hardening — throttle, dedup, validation gaps

2 participants