Extract dunite-geoip for the login-location signal (DEV-525) #32

Merged
longjacksonle merged 1 commit from feat/DEV-525-dunite-geoip into main 2026-08-04 17:53:22 +02:00

New crate for DEV-525, epic DEV-495. a8n-tools is gaining new-login-location alerts, which bunyip already has (BUNYIP-366). The whole signal is framework- and storage-agnostic, so it lands here rather than as a second copy.

Three pieces

  • GeoIpService - IP to country against an IP2Location LITE .BIN, the same file the ecosystem already deploys for dmarc-reporter. Lookups are offline: no per-login external call, no client IP handed to a third party.
  • is_non_public_ip - the addresses that can never carry a country.
  • login_location_decision - record silently, do nothing, or alert.

Why is_non_public_ip is the part most worth sharing

Without it, a request arriving with no real client IP (behind a misconfigured proxy, or any request in development) resolves to no country. The next request that does resolve then reads as a country change and mails the user an alert about a login from their own desk. That is subtle enough to be worth owning in one place rather than rediscovering per app.

Similarly, login_location_decision keeps the first attributable login silent. Alerting on it would mail every existing user the moment the feature ships, and an alert nobody can act on trains people to ignore the ones that matter.

Error type

GeoIpError is owned here, not dunite_core::AppError. Returning the shared error type forces every consumer onto one dunite-core revision - the version cascade DEV-515 hit and backed out of. Consumers convert at the call site.

Not here

The user row, the mailer, and the config. This crate never touches a database, never sends mail and never reads an environment variable. A consumer passes the previous country and the current IP and acts on the answer, which is what lets an actix app and an axum one share it.

Tests

10 unit tests. The ones that matter cover the placeholder values IP2Location stores for unknown and reserved ranges ("-" and blanks): treating one as a country would turn every login from an unlisted range into a location change and an email.

Adoption

a8n-tools/saas consumes this in the follow-up PR. bunyip still carries its own copy in bunyip-domain/src/services/geoip.rs plus the two helpers in services/auth.rs; swapping it over is a separate ticket rather than something to bundle here.

New crate for DEV-525, epic DEV-495. a8n-tools is gaining new-login-location alerts, which bunyip already has (BUNYIP-366). The whole signal is framework- and storage-agnostic, so it lands here rather than as a second copy. ### Three pieces - `GeoIpService` - IP to country against an IP2Location LITE `.BIN`, the same file the ecosystem already deploys for dmarc-reporter. Lookups are offline: no per-login external call, no client IP handed to a third party. - `is_non_public_ip` - the addresses that can never carry a country. - `login_location_decision` - record silently, do nothing, or alert. ### Why `is_non_public_ip` is the part most worth sharing Without it, a request arriving with no real client IP (behind a misconfigured proxy, or any request in development) resolves to no country. The next request that does resolve then reads as a country change and mails the user an alert about a login from their own desk. That is subtle enough to be worth owning in one place rather than rediscovering per app. Similarly, `login_location_decision` keeps the first attributable login silent. Alerting on it would mail every existing user the moment the feature ships, and an alert nobody can act on trains people to ignore the ones that matter. ### Error type `GeoIpError` is owned here, not `dunite_core::AppError`. Returning the shared error type forces every consumer onto one `dunite-core` revision - the version cascade DEV-515 hit and backed out of. Consumers convert at the call site. ### Not here The user row, the mailer, and the config. This crate never touches a database, never sends mail and never reads an environment variable. A consumer passes the previous country and the current IP and acts on the answer, which is what lets an actix app and an axum one share it. ### Tests 10 unit tests. The ones that matter cover the placeholder values IP2Location stores for unknown and reserved ranges (`"-"` and blanks): treating one as a country would turn every login from an unlisted range into a location change and an email. ### Adoption a8n-tools/saas consumes this in the follow-up PR. bunyip still carries its own copy in `bunyip-domain/src/services/geoip.rs` plus the two helpers in `services/auth.rs`; swapping it over is a separate ticket rather than something to bundle here.
feat(geoip): extract dunite-geoip for the login-location signal (DEV-525)
All checks were successful
Check / fmt + clippy + test (pull_request) Successful in 24s
create-release / create-release (pull_request) Has been skipped
b526535c7c
DEV-525 is adding new-login-location alerts to a8n-tools, which bunyip already has (BUNYIP-366). The whole signal is framework- and storage-agnostic, so it lands here rather than as a second copy: an IP2Location LITE lookup, the filter for addresses that cannot carry a country, and the record / unchanged / alert branch.

`is_non_public_ip` is the part worth sharing most. Without it a request that arrives with no real client IP - behind a misconfigured proxy, or any request in development - resolves to no country, and the next request that does resolve reads as a country change and mails the user an alert about a login from their own desk. That is a subtle enough trap to be worth owning once.

`login_location_decision` keeps the first attributable login silent. Alerting on it would mail every existing user the moment the feature ships, and an alert nobody can act on trains people to ignore the ones that matter.

`GeoIpError` is owned by this crate rather than borrowed from dunite-core. Returning the shared error type would force every consumer onto one dunite-core revision, which is the version cascade DEV-515 hit and backed out of; consumers convert at the call site instead.

Lookups stay offline: no per-login external call and no client IP handed to a third party. The `.BIN` is the same file the ecosystem already deploys for dmarc-reporter.

Ten unit tests, including the placeholder country codes IP2Location stores for reserved ranges (`"-"` and blanks), which must never be treated as a country.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcwfAHYwMmiAYbMrCc7cJG
longjacksonle deleted branch feat/DEV-525-dunite-geoip 2026-08-04 17:53:22 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/dunite!32
No description provided.