DEV Community

Faelvorn538072
Faelvorn538072

Posted on

4 Audit Events Linking External Identities to Internal Account Records

The page says password recovery completions have dropped while requests are flat. On-call can see a tenant ID, a rising count of denied attempts, and no safe way to tell whether the affected people arrived through password login or an external identity provider. The least complex fix is to keep one stable internal user record, attach every external identity to it through an explicit mapping, and make password recovery operate on the internal record only after the mapping has been resolved.

That boundary gives the alert somewhere useful to point. A login address is an input, not a durable account key. Provider subject identifiers, verified addresses, and local credentials belong to authentication records; authorization, tenant membership, recovery state, and audit history belong to the internal user. Don't let the forgot-password handler silently merge those two worlds.

This is the operating rule: resolve first, authorize the recovery second, and disclose as little as possible to the requester.

How should external identities resolve to internal user records?

Use an internal, non-reassignable user ID as the target of every identity mapping. A mapping key should include the issuer and the provider's subject identifier, because a subject value is meaningful only within its issuer's namespace. The mapping points in one direction: (issuer, subject) -> internal_user_id. Email can help locate a candidate during a controlled linking ceremony, but it shouldn't become the permanent join key.

The distinction matters in B2B SaaS. One person may belong to two tenants, use workforce single sign-on for one, and retain a local credential for another. Tenant membership answers where the person may act; the external identity answers how the person authenticated; the internal user record supplies the stable subject for policy and audit. Combining all three into a row keyed by email makes a later address change look like a new person and makes account linking hard to explain during an audit.

For password recovery, look up the presented address through a normalized, verified contact index, then evaluate the internal user's eligible recovery methods. Return the same public response whether no record exists, the account is external-only, or a recovery message is accepted. OWASP recommends generic authentication and recovery responses so the interface does not become an account-enumeration oracle. The internal branch can still be precise in audit data.

The catch is a real authorization boundary. This design is not suitable when policy requires separate legal identities or hard tenant isolation even for the same human. Keep distinct internal records in that case, and make cross-tenant linking impossible by construction. The extra friction is preferable to an invisible authorization bridge.

Resolve once.

The page is late evidence

A completion-rate page fires after users have already noticed. The first screen should separate request volume, accepted recovery intents, challenge completions, and credential changes by tenant and recovery-method class. It must not put raw email addresses, reset tokens, or provider assertions into labels or logs. High-cardinality secrets are both an exposure risk and poor telemetry.

Work backward from the alert. Suppose requests stay near baseline but recovery_identity_resolved falls while denied outcomes rise. That points toward resolution or eligibility, not message delivery. If resolution remains steady and challenges expire, inspect age buckets and delivery signals instead. If credentials change but subsequent sessions remain active contrary to policy, the incident has moved from recovery reliability to session security. The dashboard should make those branches visible without requiring on-call to query personal data.

Keep the public result coarse and the private reason bounded. An internal reason such as IDENTITY_NOT_LINKED is useful; copying an arbitrary provider error string is not. Use a short allowlist of reason codes, a correlation ID, and counts partitioned only by dimensions the team has reviewed.

No guessing.

The earlier signal should usually be a ratio at a specific stage, paired with a minimum event count. A percentage computed from three attempts is noise. A raw failure count during a planned tenant migration is also noise. Route the low-volume case to a dashboard, not a pager, until enough evidence exists to justify waking someone. The exact threshold depends on observed traffic and the cost of a delayed recovery; I'm not sure a universal value exists, and a few weeks of clean baseline data would resolve that uncertainty better than an invented industry number.

Four events make the recovery path explainable

Instrument state transitions, not handler entries. Four event types are enough for the core audit story: request received, identity resolution decided, challenge consumed, and credential state changed. They are a model, not four log lines emitted regardless of outcome. Each event carries an opaque correlation ID, tenant ID, internal user ID only when resolution is allowed to reveal it internally, outcome, reason code, policy version, and timestamp.

The important word is "decided." An unresolved address and an external-only account can share the same public response while producing different private outcomes. An auditor can then follow why no local credential changed without learning the submitted address from an application log. The event for a consumed challenge should reference a one-way token fingerprint or server-side challenge ID, never the bearer token itself.

A small Go type makes the contract harder to blur:

package recovery

import "time"

type EventKind string

const (
    RequestReceived   EventKind = "recovery_request_received"
    IdentityResolved  EventKind = "recovery_identity_resolved"
    ChallengeUsed     EventKind = "recovery_challenge_used"
    CredentialChanged EventKind = "recovery_credential_changed"
)

type AuditEvent struct {
    Kind           EventKind `json:"kind"`
    CorrelationID  string    `json:"correlation_id"`
    TenantID       string    `json:"tenant_id"`
    InternalUserID string    `json:"internal_user_id,omitempty"`
    Outcome        string    `json:"outcome"`
    ReasonCode     string    `json:"reason_code"`
    PolicyVersion  string    `json:"policy_version"`
    OccurredAt     time.Time `json:"occurred_at"`
}
Enter fullscreen mode Exit fullscreen mode

The uneven field availability is intentional. InternalUserID is absent at request receipt and may remain absent after a failed resolution. Downstream consumers must accept that instead of filling the field with an email or a magic zero. The schema should reject unknown outcome and reason values at ingestion, because free-form text defeats stable alerts and makes retention review much harder.

Idempotency belongs beside the model. Challenge consumption and credential change need distinct idempotency keys and transactional guards. A retry may emit another observation that a request arrived, but it must not consume one challenge twice or advance credential state twice. Record the winning transition and treat later attempts as denied outcomes linked to the same challenge ID. This keeps an at-least-once delivery path from becoming an at-least-once password change.

Change the instrumentation before changing the threshold

Start with a shadow metric derived from the four events. Compare it with the existing completion metric for at least one normal operating cycle, and investigate differences by correlation ID. The new metric should count unique state transitions, not log lines, so retries cannot inflate either numerator or denominator. Only after the two views are understood should the new signal page on-call.

The alert should describe an action: which stage moved, which tenant class is affected, when the window began, and which runbook decision follows. It shouldn't claim that authentication is broken merely because the final conversion ratio changed. A useful page might state that resolved identities remain steady while challenge consumption is outside its recent baseline; the first action is then to inspect expiry and delivery cohorts, not edit identity links.

Security and friction pull in opposite directions here. Invalidating every active session after a password reset gives a clean containment rule but can disrupt unrelated work across devices. Preserving sessions reduces friction but leaves a stolen session alive. Pick a documented policy based on account risk, expose it in the credential-change audit event, and require stronger reauthentication for sensitive account changes. OWASP's authentication guidance supports reauthentication after risk events and sensitive actions; it also recommends login throttling rather than trusting a single credential check as the full defense.

Test the identity graph, not only the happy path

A release test should begin with model invariants. One external (issuer, subject) maps to no more than one internal user. Removing a login method does not delete tenant membership or the audit record. Changing a contact address does not rewrite historical identity keys. A recovery challenge may be consumed once, has a bounded lifetime, and cannot change a different internal user's credential. Then test sequences that ordinary endpoint tests miss: two concurrent challenge submissions, delivery retries, an address change between request and consumption, removal of a tenant membership during recovery, and a replay after credential state has advanced. Verify both the state and the four-event trace. The HTTP response can remain deliberately generic while the internal reason code changes; those are separate contracts.

Roll out the schema reader before the writer. Next, emit shadow events, validate redaction and cardinality, build the dashboard, and only then attach paging. Keep a rollback path for alert routing and event production, but don't roll back the database invariant that prevents duplicate identity mappings once data depends on it. Migrations for that constraint need a preflight report of conflicts and a reviewed resolution decision for each one.

This approach is heavier than a single users table. Stick with a single users table when an application has local passwords only, no tenant boundary, and no audit requirement; separate identity and user records may add complexity without enough benefit there. Revisit the decision when a second authentication method or a real compliance boundary appears. Once external identities and audited recovery coexist, the explicit mapping earns its keep because it gives authorization and operations a stable subject.

Thresholds spend trust

The final tuning decision is economic, but not mainly about infrastructure cost. A sensitive threshold catches a real drop earlier and also pages on-call for low-volume tenant variance, planned migrations, and harmless shifts between recovery methods. A loose threshold protects sleep while extending the time users can be locked out. Use a minimum count, a sustained window, and a separate ticket-level signal for small tenants; then review every page for whether it caused a distinct action.

False positives have a security cost. After enough unactionable pages, responders skim the tenant and reason dimensions that were added to help them. Tune against recorded event sequences, document why the threshold exists, and remove the page if nobody can name the action it demands. The recovery flow survives audit when the team can connect an external identity decision to one internal user, one policy version, and one controlled state change without exposing the identifier that began the request.

References

Top comments (0)