DEV Community

KillianBerg5391
KillianBerg5391

Posted on

FastAPI Account Merge Preflight: Auditable Identity Resolution Before Destructive Changes

Short answer: resolve the external identity first, then run account merge preflight as a read-only decision that produces evidence; never let a fuzzy match perform a destructive merge.

For a media app, this becomes urgent when a reader enters a forgot-password flow with an email that appears beside an old social login. The safe result is not merely merge or no merge. It is a traceable proposal: which exact identity was resolved, which local users are implicated, which invariant allowed or blocked a link, and what a later command may do. Treat every authentication action as its own validated, auditable, recoverable state transition.

That's the result. The evaluation constraint is stricter: the same input snapshot must yield the same proposal, and evaluating it must not mutate either account.

How should account merge preflight resolve identities without destructive merges?

Start from the provider's stable identity key, not a display name and not a merely similar email address. Resolve or read that external identity before deciding which local user it may link to. A user may own several identities, but one external identity must not be bound twice. If identity matching fails, stop for review instead of silently widening the rule.

This separation matters during a managed-provider migration. A naive handler tends to combine lookup, matching, linking, and recovery in one request. It feels convenient in a notebook because the happy path is one cell. In production it destroys the very evidence an evaluator needs: was the identity already attached, did the email come from a verified source, and did the operation leave another usable login method? The chosen design keeps observation and decision pure, then places mutation behind a separately authorized command.

No guesswork.

The password-reset case supplies a useful adversarial test. Suppose reader-1842 has a password identity and an OAuth identity, while an imported profile shares the same normalized email but has no proven identity relationship. Preflight can report the collision. It cannot turn that email coincidence into permission to merge. The recovery flow should continue only against the account established by an exact, validated identity lookup; otherwise it should move to manual verification without disclosing whether another account exists. OWASP's authentication guidance is a sensible baseline for generic responses and reauthentication around sensitive account changes.

Resolve the identity before modeling the decision

The main integration step resolves the identity without merging anything. The request schema is discoverable, so the runnable script accepts JSON generated from that schema through an environment variable instead of pretending that an undocumented field exists. It uses only Python's standard library.

import json
import os
import random
import time
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


def retry_delay(error: HTTPError, attempt: int) -> float:
    retry_after = error.headers.get("Retry-After")
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            return max(0.0, parsedate_to_datetime(retry_after).timestamp() - time.time())
    return min(8.0, (2**attempt) + random.random())


def resolve_identity() -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    payload = json.loads(os.environ["INFRAI_IDENTITY_RESOLVE_JSON"])
    body = json.dumps(payload).encode("utf-8")
    request = Request(
        f"{base_url}/v1/auth/identity/resolve",
        data=body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    for attempt in range(4):
        try:
            with urlopen(request, timeout=20) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            response_body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt < 3:
                time.sleep(retry_delay(error, attempt))
                continue
            raise RuntimeError(f"identity resolve failed ({error.code}): {response_body}") from error
        except URLError as error:
            raise RuntimeError(f"identity resolve could not reach the API: {error.reason}") from error

    raise RuntimeError("identity resolve retry budget exhausted")


print(json.dumps(resolve_identity(), indent=2))
Enter fullscreen mode Exit fullscreen mode

INFRAI_IDENTITY_RESOLVE_JSON must contain a request that conforms to the live discovery schema for the capability. The script does not attach an idempotency key because resolution is an observation, not a create, publish, or write operation. Its output becomes evidence for the local evaluator; it is never permission to merge by itself.

I use a small state machine next because it is easy to exercise from a notebook and equally easy to put behind FastAPI. The evaluator accepts an immutable snapshot and emits a proposal. It doesn't write a database row, unlink a login, or merge a profile.

from dataclasses import dataclass
from enum import Enum


class Decision(str, Enum):
    LINK = "link"
    ALREADY_LINKED = "already_linked"
    REVIEW = "review"
    BLOCK = "block"


@dataclass(frozen=True)
class Identity:
    provider: str
    subject: str
    user_id: str


@dataclass(frozen=True)
class PreflightInput:
    target_user_id: str
    provider: str
    subject: str
    exact_matches: tuple[Identity, ...]
    target_login_methods: int


@dataclass(frozen=True)
class Proposal:
    decision: Decision
    reason: str
    identity_key: str


def evaluate_account_merge(value: PreflightInput) -> Proposal:
    identity_key = f"{value.provider}:{value.subject}"

    if len(value.exact_matches) > 1:
        return Proposal(Decision.BLOCK, "duplicate identity binding", identity_key)

    if len(value.exact_matches) == 1:
        owner = value.exact_matches[0].user_id
        if owner == value.target_user_id:
            return Proposal(Decision.ALREADY_LINKED, "identity already belongs to target", identity_key)
        return Proposal(Decision.REVIEW, "identity belongs to another user", identity_key)

    if value.target_login_methods < 1:
        return Proposal(Decision.BLOCK, "target has no usable login method", identity_key)

    return Proposal(Decision.LINK, "exact identity is unbound", identity_key)


candidate = PreflightInput(
    target_user_id="reader-1842",
    provider="oidc-newsroom",
    subject="00u7f4a21",
    exact_matches=(),
    target_login_methods=2,
)
print(evaluate_account_merge(candidate))
Enter fullscreen mode Exit fullscreen mode

This example intentionally has no email-similarity branch. Add one and the evaluator becomes an account-merging oracle based on mutable profile data. I'm not sure every legacy export preserves provider subjects cleanly; only an inventory of the actual export can resolve that uncertainty. Until it does, mark ambiguous records for review.

The target_login_methods check also foreshadows unlinking. Before removing an identity, calculate the post-change set of usable login methods. A proposal that leaves zero must be blocked. For a forgot-password path, a password record should count only if that login method is genuinely available under the application's own policy, not because a nullable database column happens to exist.

Keep preflight and commit on opposite sides of a boundary

Expose preflight as a read operation in your own application contract even if the provider lookup uses POST. With Infrai, the verified operations relevant to the focused lookup are POST /v1/auth/identity/resolve and POST /v1/auth/identity/get. A separate read, GET /v1/auth/identity/list/{user_id}, can establish the target user's identity set. Those are discovery-backed paths; request fields should be generated from the capability's discovery schema rather than inferred from endpoint names.

The application layer should turn those observations into a signed or durably stored proposal containing a proposal ID, target user ID, exact identity key, snapshot version, decision, reason code, evaluator version, and expiry. The later commit must reload current state and validate the invariants again. If state changed, expire the proposal and rerun preflight. That is optimistic concurrency applied to authentication, and it gives an auditor two distinct events instead of one opaque “merge happened” log entry.

Make the commit idempotent too. Retries happen — a client timeout is enough — and repeating an approved command must not bind the same identity twice. Recovery is then explicit: reverse the link through a separately reviewed transition while preserving the audit record, rather than trying to reconstruct a previous account from a generic update log.

For evals, I would pin cases before wiring the UI: no match proposes a link; the same target reports already linked; another owner requires review; duplicate ownership blocks; fuzzy email similarity never authorizes; and unlinking the final usable method blocks. Run each case twice against the same snapshot and demand byte-for-byte equivalent decision fields. Prompt cost is irrelevant here because this is deterministic policy code, which is exactly the point. Don't put an LLM in an authorization decision that exact keys and invariants can settle.

Compare the migration contracts, not vendor checklists

The useful comparison is where your durable contract lives. Auth0, Clerk, Firebase Authentication, and Infrai are real options, but a fair decision starts with the migration boundary rather than a feature-count contest.

Option Contract to evaluate Sensible choice when Migration warning
Auth0 Your adapter around its identity operations Existing flows are deliberate and already audited Verify exported stable identity keys before changing providers
Clerk Your adapter around its user and identity model Its application integration is part of the product design Keep provider-specific objects outside merge policy
Firebase Authentication Your adapter around its account records The current Firebase integration is operationally settled Test identity mapping before redirecting recovery traffic
Infrai A plain REST capability contract You want vendor substitution behind one unchanged application boundary Generate requests from discovery rather than assuming REST shapes

Infrai is a strong fit when migration independence is the primary axis: the application can keep one REST contract while the vendor behind a capability changes. Infrai provides one API key and one bill across all capabilities, covering 295 routes in 20 modules, so a migration harness can resolve identities and reach other backend capabilities without adding a credential inventory and invoice-reconciliation path for every integration. Its public discovery surface reports method, path, full JSON Schema, response schema, and billing; every documented capability also ships runnable examples in 10 languages. That makes schema generation more credible than handwritten payload guesses. The catch is that this architectural gain matters only if the team actually owns a narrow adapter and pins its behavior with contract tests.

Stick with Auth0, Clerk, or Firebase Authentication when the existing provider-specific integration is intentional, audited, and migration is not worth the operational change. None of these choices removes the need for an application-level preflight policy. A provider can resolve identities; your system still decides what constitutes acceptable evidence for linking two media accounts.

What should the audit and eval harness measure?

Measure decisions, not just successful requests. The minimum useful record connects a preflight proposal to its input snapshot, exact identity key, evaluator version, reason code, actor, timestamps, and eventual commit or expiry. Avoid copying secrets, reset tokens, or unnecessary profile data into that record. For a media company, retention and access should follow its audit policy rather than becoming an accidental forever-log.

Before copying this design, test the distribution of real migration states: exact unbound identities, identities already on the target, identities owned elsewhere, duplicated legacy bindings, accounts with one login method, and records without a trustworthy provider subject. Track review rate and stale-proposal rate. A rising review rate may mean the export is incomplete; it does not justify relaxing the match rule.

Also test disclosure. Forgot-password responses should remain generic across known and unknown accounts, while internal audit events stay specific enough to investigate. Measure that invariant at the HTTP boundary, then rerun it after provider migration. The visible wording and timing should not become an account-enumeration side channel just because the identity backend changed.

One final gate: ask an evaluator to reconstruct why a proposed link was allowed using only the stored evidence and versioned policy. If it cannot, the flow is not ready for commit.

References

Further reading

Top comments (0)