Skip to content

ENG-3013: Add verify email flow for admin users#8180

Draft
nreyes-dev wants to merge 7 commits into
mainfrom
nreyes/eng-3013
Draft

ENG-3013: Add verify email flow for admin users#8180
nreyes-dev wants to merge 7 commits into
mainfrom
nreyes/eng-3013

Conversation

@nreyes-dev
Copy link
Copy Markdown
Contributor

@nreyes-dev nreyes-dev commented May 14, 2026

Ticket ENG-3013

Description Of Changes

Follow-up to ENG-2931 (self-service password reset). That PR introduced email_verified_at on FidesUser and started setting it during invite acceptance, but pre-existing admin users — i.e. anyone who didn't accept an invite — still have it NULL and so can't use forgot-password. This adds the self-serve verification path for that cohort: an unverified user sees a banner CTA that emails them a tokenized link, and the /verify-email page consumes the token, sets email_verified_at, and auto-logs them in. Gated on email messaging being configured (otherwise the flow can't complete) and on the user not being SSO-only in Plus.

Code Changes

  • New FidesUserEmailVerification model + alembic migration (835de27d8c76) for hashed, single-use, TTL-bound verification tokens.
  • New email_verification_token_ttl_minutes setting in SecuritySettings (default 30, range 15–60).
  • New MessagingActionType.EMAIL_VERIFICATION, EmailVerificationBodyParams schema, email_verification.html template, and dispatch-service branch.
  • New UserService.request_email_verification and UserService.verify_email_with_token.
  • New POST /user/request-email-verification (authenticated) and POST /user/verify-email-with-token (unauthenticated, returns UserLoginResponse) endpoints.
  • New email_verification.requested / .completed / .token_expired audit event types.
  • New EmailVerificationBanner rendered globally from _app.tsx, gated on messaging/email-invite/status, hidden for verified users and for SSO-only users (password_login_enabled === false). Three visual states: unverified-with-email, no-email-yet, and "sent" confirmation. 7-day localStorage snooze keyed on userId:email_address so an email change re-shows the banner.
  • New /verify-email Next.js page added to the unprotected-routes branch in _app.tsx.
  • New requestEmailVerification and verifyEmailWithToken RTK Query mutations in auth.slice.ts.
  • "Verified" / "Not verified" Tag next to the email field in UserForm, with the verification date in a tooltip on the verified state. Hidden when not verified AND messaging is disabled (no actionable path).
  • #email_address deep-link handling in UserForm: scrolls the email field into view, focuses it, and adds a brief blue ring — so the banner's "Add email" CTA lands users directly on the field.
  • Data-category annotations for the new fides_user_email_verification table in .fides/db_dataset.yml, so fides_db_scan stays green.
  • Backend tests in tests/ops/api/v1/endpoints/test_email_verification.py (14 tests covering request_email_verification and verify_email_with_token across unauthenticated rejection, already-verified / no-email / disabled-user / messaging-unconfigured skips, token creation + replacement, dispatch failure auditing, valid/invalid/missing/wrong/expired token paths) and Cypress specs in cypress/e2e/email-verification.cy.ts.

Steps to Confirm

  1. In Settings → Messaging, configure an email provider with any non-empty values — actual delivery doesn't need to work; dispatch failures are swallowed and the token still lands in the DB.
  2. In a Python shell on the running container, create an admin user with email_address set, email_verified_at = NULL, disabled = False.
  3. Log in as that user. The yellow "Verify your email address" banner should appear on every authenticated route (home, /user-management, etc.).
  4. Click Send verification email → banner flips to the green "sent" state; a row exists in fides_user_email_verification and an email_verification.requested row exists in event_audit.
  5. Inject a known plaintext token via FidesUserEmailVerification.create_or_replace(db, user_id=<id>, token="TEST"), then visit /verify-email?username=<user>&token=TEST. Expect auto-login + redirect to /. In the DB: email_verified_at is now set, the verification row is gone, and an email_verification.completed audit event exists.
  6. Visit the same URL again → error state ("This verification link is invalid or has expired."). Repeat with created_at aged past the TTL → same error plus an email_verification.token_expired audit row.
  7. Re-null the user's email_verified_at, dismiss the banner with ×. Reload — it stays hidden. Set the matching fides:email-verification-banner-snooze:* localStorage value to a timestamp >7 days ago, reload — banner returns.
  8. Null out the user's email_address → banner switches to "Add an email address" copy. Click Add email → lands on /user-management/profile/<id>#email_address with the email field scrolled into view, focused, and briefly ringed in blue.
  9. On any user profile, confirm the Verified / Not verified Tag next to the email field. Hover the verified tag → tooltip shows the verification date.

Pre-Merge Checklist

  • Issue requirements met
  • All CI pipelines succeeded
  • CHANGELOG.md updated
    • Add a db-migration This indicates that a change includes a database migration label to the entry if your change includes a DB migration
    • Add a high-risk This issue suggests changes that have a high-probability of breaking existing code label to the entry if your change includes a high-risk change (i.e. potential for performance impact or unexpected regression) that should be flagged
    • Updates unreleased work already in Changelog, no new entry necessary
  • UX feedback:
    • All UX related changes have been reviewed by a designer
    • No UX review needed
  • Followup issues:
    • Followup issues created
    • No followup issues
  • Database migrations:
    • Ensure that your downrev is up to date with the latest revision on main
    • Ensure that your downgrade() migration is correct and works
      • If a downgrade migration is not possible for this change, please call this out in the PR description!
    • No migrations
  • Documentation:
    • Documentation complete, PR opened in fidesdocs
    • Documentation issue created in fidesdocs
    • If there are any new client scopes created as part of the pull request, remember to update public-facing documentation that references our scope registry
    • No documentation updates required

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fides-plus-nightly Ready Ready Preview, Comment May 22, 2026 3:19pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
fides-privacy-center Ignored Ignored May 22, 2026 3:19pm

Request Review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 14, 2026

Title Lines Statements Branches Functions
admin-ui Coverage: 8%
6.85% (3150/45935) 6.24% (1655/26520) 4.76% (647/13565)
fides-js Coverage: 78%
79.17% (1977/2497) 66.25% (1249/1885) 73.31% (349/476)
privacy-center Coverage: 85%
82.53% (364/441) 79.74% (189/237) 74.07% (60/81)

@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

Codecov Report

❌ Patch coverage is 92.43697% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.10%. Comparing base (a5fe819) to head (c62960e).
⚠️ Report is 14 commits behind head on main.

Files with missing lines Patch % Lines
.../fides/api/models/fides_user_email_verification.py 89.74% 2 Missing and 2 partials ⚠️
.../api/service/messaging/message_dispatch_service.py 0.00% 2 Missing and 1 partial ⚠️
...rc/fides/api/email_templates/get_email_template.py 0.00% 1 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (92.43%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8180      +/-   ##
==========================================
+ Coverage   85.08%   85.10%   +0.02%     
==========================================
  Files         670      671       +1     
  Lines       43564    43683     +119     
  Branches     5111     5124      +13     
==========================================
+ Hits        37065    37175     +110     
- Misses       5394     5399       +5     
- Partials     1105     1109       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nreyes-dev
Copy link
Copy Markdown
Contributor Author

/code-review

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code Review — ENG-3013: Email verification flow for admin users

Overall this is a well-structured, well-tested PR. The implementation closely mirrors the existing password-reset flow, the backend tests cover all the meaningful edge cases (expired token, wrong token, no record, dispatch failure), and the Cypress suite covers the full banner lifecycle including snooze TTL and email-change invalidation. A few issues worth addressing, ranging from a security gap to polish items.


Security

Disabled user can complete verification and obtain a session (user_service.py:442)
verify_email_with_token does not check user.disabled before calling perform_login. A user disabled after requesting verification can still consume the token and receive a valid session. request_email_verification already guards against this on the request side — the token-consumption side should mirror it. See inline comment for suggested fix.


Correctness

Non-atomic create_or_replace (fides_user_email_verification.py:49)
The delete → create pattern is inherited from FidesUserPasswordReset and the risk is low in practice, but if create raises after delete has committed, the user loses their record and must request again with no indication of what happened. An upsert would eliminate this window; at minimum, a DB savepoint around the two operations would make rollback reliable.

Token record not deleted on failed token_valid() check (user_service.py:468)
A wrong token leaves the record in place, allowing repeated guessing attempts within the rate-limit window. The TTL and rate limiter together are a reasonable mitigation, and the existing password-reset path has the same behavior. Worth a conscious follow-up decision.


Minor / polish

  • is_expired() timezone assumption (fides_user_email_verification.py:82): self.created_at is a DateTime(timezone=True) column so SQLAlchemy should return tz-aware objects from Postgres, but if the value is set tz-naively in a test or migration, the comparison with datetime.now(timezone.utc) will raise TypeError. The pattern is identical in FidesUserPasswordReset so it works in practice, but explicit tzinfo handling would be safer.

  • eslint-disable for hooks deps (verify-email.tsx:54): The comment explaining why verifyEmailWithToken is excluded is accurate, but a useRef-based "has-fired" guard would let you keep the deps array complete without suppressing the lint rule. See inline comment for a pattern.

  • EmailVerificationBanner loading state (EmailVerificationBanner.tsx:60): No isLoading guard on the email-invite-status query. Behavior is already correct (renders null while undefined), but an explicit isLoading check would make the intent clearer and prevent a visible banner pop-in on slow connections.

  • _app.tsx OR chain (_app.tsx:62): Now at 4 entries; the comment already flags the refactor need. Good candidate for a follow-up ticket.

  • Email template plaintext fallback (email_verification.html:9): The verification URL only exists in an anchor href. Adding a plaintext copy-paste URL below the link improves deliverability in plain-text clients and strict corporate email filters.


🔬 Codegraph: connected (50455 nodes)


💡 Write /code-review in a comment to re-run this review.

Raises:
FidesError: If the token is invalid, expired, or the user is not found.
"""
user = FidesUser.get_by(self.db, field="username", value=username)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

src/fides/service/user/user_service.py:442

verify_email_with_token does not check whether the user is disabled before issuing a session token. If a user is disabled after they request a verification email (but before they click the link), the token would still complete successfully and grant them an active session via perform_login.

request_email_verification already guards against disabled users (line 374), but the consuming side doesn't mirror that check.

Suggested fix — add immediately after the user lookup succeeds:

if user.disabled:
    raise FidesError("Invalid or expired verification token.")

Using the same opaque error message prevents disclosing that the account is disabled to an unauthenticated caller.

Note: reset_password_with_token has the same gap; worth patching there in a follow-up if not here.

matching_verification.delete(self.db)
raise FidesError("Invalid or expired verification token.")

if not matching_verification.token_valid(token):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

src/fides/service/user/user_service.py:468

When token_valid() returns False, the verification record is left intact. Within the rate-limit window, a caller can make repeated guesses against the same record without consuming it. The TTL provides a natural expiry, but explicitly deleting the record on a failed validation attempt would be a tighter control (at the cost of forcing the user to request a fresh token on any typo/link corruption).

This is consistent with how reset_password_with_token behaves, so happy to treat it as a follow-up, but worth a conscious decision here given that the token is only 36 bytes of UUID entropy and the rate limiter is the only active countermeasure.

cls, db: Session, *, user_id: str, token: str
) -> FidesUserEmailVerification:
"""Create a verification record, replacing any existing one for this user."""
existing = cls.get_by(db, field="user_id", value=user_id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

src/fides/api/models/fides_user_email_verification.py:49

create_or_replace performs a delete followed by a separate create. If super().create() raises after existing.delete(db) has already committed (e.g., a DB constraint violation or connection error), the user is left with no verification record and would need to request a new token — even though they just clicked the CTA.

This is the same pattern used in FidesUserPasswordReset.create_or_replace, so the risk is accepted elsewhere. A safer alternative would be an upsert (e.g., INSERT … ON CONFLICT (user_id) DO UPDATE SET …), which would make the operation atomic, but that's a broader refactor. At minimum, consider wrapping this in a savepoint/subtransaction so the delete is rolled back if the create fails.

return True

ttl_minutes = CONFIG.security.email_verification_token_ttl_minutes
expiration_datetime = self.created_at + timedelta(minutes=ttl_minutes)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

src/fides/api/models/fides_user_email_verification.py:82

self.created_at is a DateTime(timezone=True) column, so SQLAlchemy should return a tz-aware object from PostgreSQL. However, if created_at happens to be tz-naive (e.g., set directly in tests without a timezone argument), comparing against datetime.now(timezone.utc) (tz-aware) will raise a TypeError at runtime.

The same pattern exists in FidesUserPasswordReset.is_expired, so this likely works in practice, but it's worth being explicit:

expiration_datetime = self.created_at.replace(tzinfo=timezone.utc) if self.created_at.tzinfo is None else self.created_at
expiration_datetime = expiration_datetime + timedelta(minutes=ttl_minutes)

Or simply ensure all test fixtures set created_at with timezone.utc.

}).unwrap();
if (cancelled) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

clients/admin-ui/src/pages/verify-email.tsx:54

The eslint-disable-next-line react-hooks/exhaustive-deps suppresses a real warning: verifyEmailWithToken is excluded from the dependency array. The comment justification ("RTK Query hook tuples are stable in practice") is accurate, but suppressing the rule means any future change to this effect won't get a lint warning.

A common alternative that avoids the suppression is to hold a ref to whether the request has already fired:

const hasFiredRef = useRef(false);

useEffect(() => {
  if (!router.isReady || hasFiredRef.current) return undefined;
  // ...
  hasFiredRef.current = true;
  // fire request
}, [router.isReady, router.query.username, router.query.token, verifyEmailWithToken]);

This keeps the deps array complete and prevents any double-fire without relying on implementation details of RTK Query.

const { data: emailInviteStatus } = useGetEmailInviteStatusQuery(undefined, {
skip: !user || Boolean(user.email_verified_at),
});
const [requestEmailVerification, { isLoading: isRequesting }] =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

clients/admin-ui/src/features/common/EmailVerificationBanner.tsx:60

The useGetEmailInviteStatusQuery call has no loading state guard. When emailInviteStatus is undefined (query in-flight), !emailInviteStatus?.enabled evaluates to true and the component renders null, which is the right behavior. But there's a subtle UX edge: if the query is slow (e.g., cold start), an eligible user will see the banner appear with a short delay after the page loads.

This is a minor polish issue — adding an explicit isLoading check and rendering null during load would make the intent clearer, though the behavior is already correct:

const { data: emailInviteStatus, isLoading: emailInviteStatusLoading } = useGetEmailInviteStatusQuery(...);
// ...
if (emailInviteStatusLoading || !emailInviteStatus?.enabled) return null;

Comment thread clients/admin-ui/src/pages/_app.tsx Outdated
// Only the login page is accessible while logged out. If there is
// a use case for more unprotected routes, Next has a guide for
// per-page layouts:
Component === ForgotPassword ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

clients/admin-ui/src/pages/_app.tsx:62

The existing comment already flags this OR chain as a candidate for refactoring. With VerifyEmail now added as the 4th entry, and the pattern likely to grow (any future unprotected route must be added here manually), this feels like the right moment to establish a per-page opt-out mechanism — e.g., a getLayout static property or a noAuth export on the page component — rather than accumulating more entries. That said, this could be a follow-up tracked as tech debt rather than blocking this PR.

</head>
<body>
<main>
<p>Please <a href="{{admin_ui_url}}/verify-email?token={{verification_token}}&amp;username={{username | urlencode}}">verify your Fides account email address</a> to enable account recovery features such as self-service password reset.</p>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

src/fides/api/email_templates/templates/email_verification.html:9

The verification URL is only present as the href of an anchor tag. If an email client strips HTML or the user's client renders plain text, there's no visible URL to copy/paste. The password_reset.html template has the same limitation, so this is consistent, but it's worth noting as a potential deliverability/UX issue — some strict corporate email filters also rewrite or block HTML links.

Consider adding a fallback plaintext URL below the link (as many well-known verification emails do):

<p>Or copy and paste this URL into your browser:<br>
{{admin_ui_url}}/verify-email?token={{verification_token}}&amp;username={{username | urlencode}}</p>

@nreyes-dev nreyes-dev added the do not merge Please don't merge yet, bad things will happen if you do label May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do not merge Please don't merge yet, bad things will happen if you do

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant