Network telemetry knows IP addresses; identity telemetry knows users; and the gap between them is where account-takeover investigations stall. This guide implements the time-bounded IP-to-user binding that joins the two, as one precise technique within cross-source event linking and the broader alert correlation rule engines pipeline.

Root-Cause Context

A proxy log says 203.0.113.9 exfiltrated 4 GB to a rare ASN; an identity log says jdoe authenticated from a new device. Only by binding that IP to that user, at that time, does the SOC learn they are the same event — that jdoe’s session exfiltrated the data. The binding is hard because IP-to-user is a temporal relationship: DHCP leases, VPN pool assignments, and NAT gateways mean an address maps to different principals at different moments, so a static IP-to-user table is wrong the moment it is written.

Two failure modes dominate. First, stale binding: joining on an IP without a time bound attributes one user’s network activity to whoever held that address yesterday. Second, NAT collapse: many users behind one egress IP make a naive IP join fuse unrelated people into a phantom shared session. The fix is a lease-style binding derived from authoritative session-start events (VPN connect, 802.1X, DHCP, IdP session establishment) with an explicit validity interval, so a network event is attributed only to the principal who actually held the address at that instant.

Time-bounded IP-to-user binding joining identity and network events Session-start events from VPN, 802.1X, and the identity provider establish time-bounded bindings of an IP address to a principal. A network event carrying only an IP is joined to the principal whose binding interval contains the event's timestamp, producing a single attributed record; an event whose IP has no active binding fails closed to a dead-letter queue. Time-bounded IP → principal binding Session-start events VPN · 802.1X · IdP IP + user + [t0, t1] Binding table interval-keyed lease Network events proxy · flow · firewall IP + timestamp Attributed record principal ⊕ network

Prerequisites

The implementation targets Python 3.11+ and uses only the standard library plus pydantic. It assumes IP addresses and timestamps are normalized upstream by JSON event normalization, and that resolved principals feed the parent cross-source event linking window.

pip install "pydantic>=2.6,<3.0"

Production-Ready Implementation

The binding store holds interval leases per IP; a network event is attributed to the lease whose interval contains its timestamp. Overlapping or absent leases fail closed rather than guessing.

from __future__ import annotations

import bisect
import logging
from dataclasses import dataclass

from pydantic import BaseModel, Field

logger = logging.getLogger("soc.correlation.ipuser")


class NetworkEvent(BaseModel):
    event_id: str = Field(min_length=1)
    src_ip: str = Field(min_length=1)
    event_time: float = Field(gt=0)


@dataclass(frozen=True)
class Lease:
    principal: str
    start: float
    end: float          # inclusive upper bound; use +inf for open leases


class IpUserBinder:
    def __init__(self) -> None:
        # Per-IP sorted lease starts for binary-search lookup.
        self._leases: dict[str, list[Lease]] = {}

    def add_lease(self, ip: str, lease: Lease) -> None:
        arr = self._leases.setdefault(ip, [])
        idx = bisect.bisect_left([l.start for l in arr], lease.start)
        arr.insert(idx, lease)

    def resolve(self, ev: NetworkEvent) -> str | None:
        """Return the principal whose lease covers ev.event_time, else None."""
        arr = self._leases.get(ev.src_ip)
        if not arr:
            return None                                  # -> ERR_LINK_061
        # Find the latest lease starting at or before the event time.
        starts = [l.start for l in arr]
        idx = bisect.bisect_right(starts, ev.event_time) - 1
        if idx < 0:
            return None
        lease = arr[idx]
        if lease.start <= ev.event_time <= lease.end:
            return lease.principal
        return None                                      # gap between leases

    def attribute(self, ev: NetworkEvent) -> dict | None:
        principal = self.resolve(ev)
        if principal is None:
            logger.warning("ERR_LINK_061 no binding ip=%s t=%.0f",
                           ev.src_ip, ev.event_time)
            return None
        return {"event_id": ev.event_id, "principal": principal,
                "src_ip": ev.src_ip, "event_time": ev.event_time}


if __name__ == "__main__":
    binder = IpUserBinder()
    binder.add_lease("10.2.0.7", Lease("alice", 1_000_000.0, 1_003_600.0))
    binder.add_lease("10.2.0.7", Lease("bob", 1_003_600.1, 1_007_200.0))
    ev = NetworkEvent(event_id="n1", src_ip="10.2.0.7", event_time=1_002_000.0)
    assert binder.attribute(ev)["principal"] == "alice"

The binary search over lease starts makes attribution O(log n) per event even for an address that has churned through hundreds of leases in a day. Open-ended leases (end = float("inf")) model still-active sessions; they are closed when the corresponding session-end event arrives.

Error-Code Reference

Code Meaning Action
ERR_LINK_061 No active binding for the IP at the event time Route to DLQ; replay after the session-start event is ingested
ERR_LINK_062 Overlapping leases for one IP (NAT or data error) Prefer the most specific source; flag for reconciliation
ERR_LINK_063 Network event missing IP or timestamp Reject to DLQ; fix normalization
ERR_LINK_064 Lease source (VPN/DHCP) lagging behind network events Buffer network events briefly; alert on sustained lag

Operational Notes

  • Bindings are leases, not a table. Always attribute by the interval that contains the event timestamp; never by “whoever holds this IP now.”
  • NAT needs a finer key. Behind a shared egress IP, fall back to a more specific identifier (source port range, x-forwarded-for, or an inner VPN address) or decline to attribute rather than fusing users.
  • Watch lease-source lag. If VPN or DHCP logs arrive after the network events they should bind, briefly buffer network events so the lease exists before attribution — the same lateness discipline used in temporal correlation windows.

Verification Checklist

FAQ

Why not just maintain a current IP-to-user map?

Because IP-to-user is temporal. DHCP, VPN pools, and NAT reassign addresses constantly, so a “current” map attributes a network event to whoever holds the IP now, not who held it when the event occurred. Interval leases keyed by an authoritative session-start event let you attribute each event to the principal who actually held the address at that instant, which is the only correct basis for an investigation.

How do I handle many users behind one NAT gateway?

A shared egress IP cannot be attributed on IP alone without fusing unrelated users. Fall back to a finer key — a source-port range the NAT device logs, an x-forwarded-for header, or the inner VPN-assigned address — or explicitly decline to attribute and record the ambiguity. Fusing users is worse than declining, because a phantom shared session corrupts every downstream correlation.