alumni_lookup

Phase 28.2 — Google Sign-In

Status: ✅ Complete — shipped August 14, 2026 Effort Class: Medium. One migration, one route, one controller, one resolver, a policy bump. Surfaces: Signup (alum-facing) + shared model. No held-surface writes — see §2. Prerequisites: 28.1. Auto-linking assumes one row per email.


1. What Ships

A “Continue with Google” button on /sign-up and /sign-in, both posting to one endpoint, resolving through one four-way resolver, and never creating a record.

Coverage on day one is roughly two-thirds of returning alumni (134 of 200 active rows are gmail.com) with nobody having linked anything first, because a verified email match auto-links on first use. This is why the button ships before the Connected Accounts screen rather than after it.

2. The Route Collision, and Why the Provider Gets a Second Name

GET /auth/google_oauth2/callback on alumnichampions.com already resolves to Cp::OmniauthCallbacksController. The portal’s route constraint matches the signup domain as well as its own (routes.rb:9-13) and the callback sits inside it (routes.rb:43).

Three ways out, two of them wrong:

Approach Verdict
Narrow the Cp:: constraint to drop alumnichampions Rejected. Changes held-surface routing behavior. Needs approval this phase does not have, and the blast radius is a frozen app nobody is currently testing.
Add a signup route ahead of the Cp:: one Rejected. Same path, resolved by declaration order. Works until someone reorders the file, and nothing would fail loudly when they do.
Register a second provider name Chosen. /auth/signup_google/callback — no collision, no held-surface edit, no ordering dependency.
# config/initializers/devise.rb — a SECOND entry alongside the existing one
config.omniauth :signup_google, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_CLIENT_SECRET"],
  { strategy_class: OmniAuth::Strategies::GoogleOauth2,
    scope: "email,profile", prompt: "select_account", name: "signup_google" }

Same Google Cloud project, same credentials, one additional authorized redirect URI — which alumnichampions.com needs registered regardless, since it has never had one.

Route goes at root level, host-constrained to the signup domain, outside the Cp:: block. Not inside a devise_scopeChampionSignup is not a Devise model and the callback controller inherits from Signup::BaseController, not Devise::OmniauthCallbacksController.

3. The Resolver

One method, four outcomes, in order. Lives in Signup::GoogleIdentity (a service, not a model method — it reaches across ChampionSignup, Alumni, and AlumniLookupService, and none of those is a natural owner; same reasoning that put Signup::Identity in app/services/signup/).

# Match on Result Event
1 google_uid Sign in signin_completed, method: "google"
2 Verified email → find_active_by_email Sign in, store google_uid signin_completed + google_linked (auto: true)
3 Verified email → AlumniLookupService.find(email:) → BUID → active signup Sign in, store google_uid signin_completed + google_linked (auto: true)
4 Nothing Do not create. Hand off to /sign-up with locked prefill (28.3) none — no record to attach one to

3.1 email_verified is a hard gate

Check it before steps 2 and 3. Google can return an unverified address under some Workspace configurations, and an unverified address that auto-links to an existing record is account takeover.

Neither existing implementation does this. Cp::Champion.from_omniauth (cp/champion.rb:1742) and User.from_google_oauth (user.rb:95) both match on a bare email string. On the staff surface the risk is low — admins create accounts by hand from known addresses — but this is a public funnel where anyone can present any Google identity, and auto-linking is the whole feature. Signup must be stricter than the code it is modelled on. An unverified email falls through to outcome 4.

Read from auth.info.email_verified, falling back to auth.extra.raw_info.email_verified; treat absent as false.

3.2 Step 3 is required, not a nicety

The alum who signed up with @belmont.edu and authenticates with a personal Gmail is the common case, not the edge one. Without the BUID fallback, outcome 4 hands that person a signup form and they create a second row — the exact duplicate 28.1 just made impossible to hold and expensive to resolve.

Belmont runs Microsoft, not Google Workspace (confirmed August 14, 2026), which promotes this from “improves the match rate” to “is the only path”. Those 20 rows cannot match on email under any circumstances — there is no Google identity behind a belmont.edu address to match against. Their owners will click with a personal account, and step 3 is the only thing that connects it to the record they already have.

The portal already proves the mechanism at cp/champion.rb:1749-1759 (Alumni.filter_by_any_email → BUID → existing record). Use the shared AlumniLookupService.find(email:) rather than reaching into Cp::.

3.3 The stored email is never overwritten

When step 3 matches, the authenticated address differs from the record’s. Do not update the record’s email. It may be what Advancement Services has, and CRM changes are not recallable once exported (22.3). The Google address is used for authentication and discarded — decided during planning; revisit only if a real need appears.

3.4 Already linked elsewhere

A google_uid presented while it is already stored on a different active signup: refuse, do not re-link, and say so plainly. Matches the portal’s handling (cp/omniauth_callbacks_controller.rb:124).

After 28.1 this should be unreachable — one row per email, and a Google identity carries one email. It gets an explicit branch and a test anyway, because “unreachable” is a claim about code that will change.

4. Schema

add_column :champion_signups, :google_uid, :string
add_column :champion_signups, :google_linked_at, :datetime
add_index  :champion_signups, :google_uid,
           unique: true, where: "google_uid IS NOT NULL AND deleted_at IS NULL"

Column names deliberately match users and cp_champions. If the tables ever do converge, the columns already line up — see README §2, Decision A.

Extend to_champion_attributes (champion_signup.rb:471) to carry google_uid. That is the convergence rule applied: the signup→Champion map gets extended whenever a signup field gains a Champion equivalent, so a converted signup keeps its Google link through the transition instead of silently losing it.

5. Connected Accounts

Minimal, in the profile hub: provider, the linked address, when it was connected, and a disconnect action writing google_unlinked.

Unlink is unconditionally safe here and needs none of the portal’s “last credential” logic, because passwordless is permanent — email link and code are always available as a floor. That is a structural consequence of Phase 27 Decision A, not a coincidence, and it is why this screen is cheap on this surface and annoying on the other one.

Linking still happens silently on first Google sign-in. This screen is for visibility and revocation only; it is not a prerequisite for anything.

6. Privacy Policy Bump

PRIVACY_POLICY_VERSION goes to the implementation date. Two material changes:

  1. A Google account may be connected to a profile without an explicit connect action — auto-linking on verified email match is the mechanism that makes the feature work on day one, and it happens without a prompt.
  2. Identity may be resolved through an address the person did not present — §3.2’s BUID fallback. This is the one that genuinely needs disclosure. Someone authenticating with a personal Gmail can be signed into a record created under a university address, because both resolve to the same BUID in the alumni system.

Rows stamped 2026-08-12 accepted neither. Both changes are the kind a reasonable person might have decided differently about had they seen them — which is what a version stamp exists to make visible.

7. Tests

Area Asserts
Resolver Each of the four outcomes, in isolation and in precedence order
Resolver email_verified: false never matches — falls to outcome 4 even with an exact email match present
Resolver Outcome 3 signs in and leaves email unchanged
Resolver A google_uid on another active row refuses rather than re-links
Resolver Outcome 4 creates no ChampionSignupassert_no_difference
Callback Both buttons hit one endpoint; destination is decided by match result, not by origin
Callback /auth/signup_google/callback on the signup host does not reach Cp::OmniauthCallbacksController
Events signin_completed carries method: "google"; google_linked written once, not on every subsequent sign-in
Connected Accounts Disconnect writes google_unlinked and leaves the record signed in
Policy Version constant bumped; consent copy names both changes

Sabotage checks, both required. Force email_verified to true unconditionally and confirm the verification test fails. Remove the Cp:: host constraint from the route test and confirm the collision test fails. A routing assertion that passes with the constraint gone is asserting nothing.

8. Settled at Implementation

9. The Microsoft Question, Deferred

A Microsoft/Entra provider would cover the belmont.edu population directly, and the resolver built here makes it genuinely cheap — a config entry, a microsoft_uid column, and one more branch in the same four-way match. It is out of scope for 28.2 and sits in BACKLOG beside Apple and Facebook.

Worth stating why it is not urgent: the people holding a belmont.edu address are current staff and recent grads, and their personal account is what they will click with. §3.2’s BUID fallback already resolves them — a personal Gmail matching an Alumni record whose email is the university address lands on the right signup. The Microsoft provider would save them nothing they cannot already do; it would only matter if someone’s only email identity were the university one.

Revisit if the miss rate on step 4 turns out to be concentrated in that domain.


10. What Was Implemented

Piece Where
Resolver app/services/signup/google_identity.rb
Callback, failure, disconnect app/controllers/signup/omniauth_callbacks_controller.rb
Second provider config/initializers/devise.rb
Routes config/routes.rb — callback, disconnect, and the strategy-constrained failure
Columns + index db/migrate/20260814210000_add_google_identity_to_champion_signups.rb
Button app/views/signup/signups/_google_button.html.erb, rendered by sessions/new and steps/_who_you_are
Connected Accounts app/views/signup/signups/show.html.erb sidebar
Handoff Signup::SignupsController#pending_google, #google_prefill, #apply_pending_google, #consume_pending_google
Tests test/services/signup/google_identity_test.rb (18), test/controllers/signup/omniauth_callbacks_controller_test.rb (21), plus model and compliance additions

Full suite after: 5932 runs, 17145 assertions, 0 failures, 0 errors, 3 skips.

11. Spec Deviations

Each of these differs from §1–§9 above. None changes a decision; they are things the spec did not say, or said in a shape the implementation proved wrong.

1. §3.4’s refusal is two shapes, only one is unreachable, and only one refuses.

The spec described a single case — a google_uid already stored on a different active signup — and called it unreachable after 28.1, correctly. Implementing it surfaced a second shape that is routinely reachable: the row we resolved already carries a different google_uid. Link a personal Gmail, then authenticate later with a second Google account whose address is also on the alumni record, and both resolve to the same BUID.

Both originally refused, and that was wrong for the second one. Reported the same day by a real two-account case (@gmail.com signup, @belmont.edu sign-in, both addresses on one alumni record): the sign-in bounced to /sign-in and the person had no way forward that wouldn’t loop.

The error was conflating two decisions:

  Warranted? Why
Signing in Yes Outcome 3 already signs this person in through the same fallback when no link is present. Making the identical credential insufficient because a link exists treats the link as a security boundary. It isn’t one — it’s a convenience record, and it does not change what the credential proves.
Moving the link No Repointing it at whoever authenticated most recently is how an identity gets taken over, and it would overwrite a connection somebody made deliberately.

So §3.4b now signs in, leaves the link alone, and says so, pointing at Connected Accounts. This is §3.3’s rule applied to the second column — a BUID match signs in and leaves the stored email alone for exactly the same reason. §3.4a still refuses, because there the uid and the email name different rows and there is no single record to open.

Residual risk, stated because it is the strongest argument against this: AlumniLookupService searches every alumni email field including email_other, so a shared household address there would let a spouse sign in as the alum. That hole is identical in outcome 3 as originally specified and approved — the link-state refusal never closed it and was never a mitigation for it. Narrowing which alumni email fields the fallback trusts is the fix if it matters, and it is its own decision.

2. Both lookups run even when the uid already matched.

The obvious implementation short-circuits (by_uid || resolve_by_identity), and that is what was written first. It makes the §3.4a branch dead code that still reads as protection — the test failed against it, which is the whole reason the spec asked for a branch and a test rather than a comment. The cost is one indexed lookup plus at most one alumni email search per sign-in, not per request.

3. Server-side email enforcement was pulled forward from 28.3.

The spec put the prefill contract wholly in 28.3. Shipping the handoff without the server half would make “prefilled from Google” a suggestion any crafted POST could ignore, and the Google link would land on a record holding an address nobody verified. #apply_pending_google overwrites the submitted address with the verified one. 28.3 still owns the visual lock, the step-1 restructure, and the status-ladder repair.

4. An unverified address is withheld from the prefill, not just from matching.

§3.1 says an unverified email “falls through to outcome 4” and stops there. It is also dropped from Result#prefill: putting an address we have no evidence for into a field 28.3 is about to lock would launder it into looking verified. The name still prefills, and the uid still attaches on create — the uid is Google’s account identifier and is trustworthy whatever the address does.

5. /auth/failure needed handling the spec did not mention.

OmniAuth derives its failure endpoint from the path prefix, so both providers fail to /auth/failure — already claimed by the portal, which redirects to the Alumni Network login page. A signup visitor declining consent at Google would have landed in a frozen surface.

Captured for signup_google only, constrained on the strategy param OmniAuth::FailureEndpoint appends. strategy=google_oauth2 falls through to the portal handler exactly as before, and a test asserts that negative — without it the constraint would be decorative. If a future OmniAuth drops the param the branch stops matching and behavior reverts to today’s: it fails toward the old path, not into a 404.

6. The button hides itself when the provider is not configured.

The launch guide asked for this to be verified on staging; it is now enforced. Without GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET the initializer skips the provider, so there is no middleware at /auth/signup_google and the button would post into a 404 — a dead control reads as a broken site rather than a feature that is switched off. Signup::BaseController#signup_google_available? reads Devise’s own registry rather than re-checking the env vars, so it follows the initializer if that condition ever changes.

7. A google_uid uniqueness validation, not only the index.

§4 specified the index alone. The validation exists for the same reason 28.1’s email one does: the common case should be a form error rather than a RecordNotUnique in front of an alum. It is nearly unreachable — the resolver refuses first — and catches the race between two simultaneous callbacks.

8. google_linked carries matched_by alongside auto.

§7 specified auto: true | false. matched_by records which of the four branches found the record (google_uid, email, buid, signup_created). The BUID one is the interesting one and the one §6 had to disclose; being able to count it is what makes that disclosure auditable rather than asserted.

9. Connected Accounts renders only when something is linked.

§5 said “minimal, in the profile hub”. There is no empty state: connecting happens by signing in with Google, not from this card, so an empty version would be chrome advertising an action it cannot perform.

10. layouts/signup had to learn to render flash.

Not in the spec because nothing on this surface had ever set one — every message here was a page-level banner rendered by the view that owned it. An OAuth callback has no page of its own, so it can only redirect and hand the reason forward, and this was the only layout in the app with nowhere for that to land. The refusal above reached the user as a silent bounce. See /debug → “A Redirect-Only Controller on a Surface Whose Layout Has No Flash”.