Skip to content

Add pluggable native Geo providers and protected CloudFront ingress - #439

Open
ChristianPavilonis wants to merge 10 commits into
feat/native-celld-storesfrom
feature/pluggable-geo-providers
Open

ChristianPavilonis wants to merge 10 commits into
feat/native-celld-storesfrom
feature/pluggable-geo-providers

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Current review readiness

Ready for review. Required current-head CI passed at 414e52ae41ee95880fd11238a22ad2dc944f44c2: test run 42924133045.

Minimal fixes originate in #414 and are merged upward:

  • Typed SDK connector timeouts map to Deadline; non-timeouts remain Unavailable. The previously failing hosted timeout test and all 78 provider cases now pass without assertion, retry or duration changes.
  • Fresh consumers use the existing locked SDK cohort: optional native aws-types =1.6.0 and aws-smithy-runtime-api =1.19.0. This prevents the schema mismatch and preserves the explicit older behavior version instead of switching retry policy or suppressing deprecations. A fresh consumer passes with compiler and Clippy warnings denied throughout.
  • The constructor-boundary test attempts the full upload while independently reading and requiring a complete, finite expected response. The existing startup-readiness poll treats a connection reset as not yet HTTP-ready within its unchanged deadline; success still requires a complete HTTP200 and the original payload checks. Production transport and limits are unchanged.

Local final-top Axum checks pass 488 unit, 28 contract, six AWS startup, four celld startup and 36 hosting cases. Final-top workspace strict Clippy and formatting pass. The prior stable checkpoint passed all six mandatory commands; unchanged inputs retain their receipts, while fresh current-head CI checks the combined state. The current ready state supersedes historical draft-only prerequisites below; unexecuted deployment acceptance is not claimed complete. An additional Fastly WASM runtime job was still running at transition and is not counted as passed. No TS work or deployment qualification is a review prerequisite. Integration #462 remains draft; reviewers aram356, prk-Jr and dhruv8sh remain requested. Stack465 and PR bases are unchanged; #453 is excluded.

Current review-stack checkpoint

Publication head: 414e52ae41ee95880fd11238a22ad2dc944f44c2. This cleanup preserves the existing feature unit and PR identity. The additional change is the completed generated-native build correction: image-only code now follows the scaffold's existing Fastly-example selection, with no request-policy change. Its focused generator/native proof is retained; fresh per-branch hosted checks are not assumed green.

Review order: #275 → #406 → #407 → #409 → #414 → #422 → #439 → #454. Draft integration checkpoint: #462. New SDK/group/codec/phase development and unrelated TS work are not prerequisites or additions to this PR. Earlier notes below retain their original tested revisions and limits; they are not a whole-tree pass for this new head.

Earlier implementation and proof notes

RC refresh checkpoint, 2026-11-10

Current head: c8ee48fcea9828095640a0fa8e593fb59c6d57fa.

Geo-only behavior is ported onto current #409/#422 instead of restoring repeated older dispatch/body code. Built-ins remain synchronous and local. One private outcome is captured from owned ingress and retained through the managed pre-dispatch seam. Native trusted_proxy naming keeps the outbound hard-cut guard byte-unchanged, without the old exemption.

Feature core/native/all-feature, Geo-wire and disabled-feature checks, dependency isolation, strict Clippy, docs/actionlint/format pass. Independent source review found no credible introduced defect. Lower CI/fixture fixes are merged; all five locked fetches and 35 CI tooling cases pass.

Protected CloudFront is a composition: ALB validates the credential value externally; EdgeZero checks the actual socket-source policy and strips the selected credential header, including normalized-head errors, before relevant application callbacks. No live ALB/CloudFront source qualification is claimed. Arbitrary unmanaged ctx.into_request plus a fresh context deliberately resets Geo to NoSelection. This adds no production KV Geo provider or local MaxMind implementation. The admitted-error finalizer is a separate follow-on.

Draft pending fresh hosted checks and the combined RC audit. Two unchanged native default-parallel deadline misses remain recorded and unclassified; serial passes and untouched-foundation parallel passes do not clear them. No timing bound was relaxed. No deployment, main merge, security-alert dismissal or review-thread resolution occurred. Historical design/review context follows.


Summary

  • Add portable validated Geo values, bounded redacted assertions, a synchronous provider contract and one immutable outcome per request.
  • Add default-off native CloudFront and supplied-provider selection, early startup validation and protected-ALB source admission based only on the owned socket peer. No fallback, remote lookup or automatic HTTP permission decision.
  • Add real hosting and portable WASM regressions, deployment guidance, compiled examples and explicit feature/dependency CI coverage.

Stack and prerequisite reconciliation

Base branch: feat/native-celld-stores, draft #422 at 5426ea65. This PR stays on top of the existing native celld stack.

Although the GitHub stack names #409 as an ancestor, this base did not contain its checked-ingress implementation. This diff therefore also reconciles the pinned 66a05e25 checked-ingress/proxy/diagnostics contract. It adapts that commit's own delta while preserving newer native stores, local non-Send initialization, Connections/FuturesUnordered, bounded ingress/config snapshots and asynchronous response EOF validation. The existing forwarding parser supplies the shared bounded peer-network matcher.

The distinct #407 buffered JSON-cap/managed Body feature is not imported or claimed. Native JSON error privacy is retained through private admitted/context state. The plan records exactly which #407-dependent cap/carrier checks are excluded. No old branch tree replaced newer files.

Changes

Crate / File Change
edgezero-core::geo Validated provider IDs, pinned ISO country allowlist, syntax-only subdivisions, bounded input slots and source-free error categories. Portable providers gain no Send/Sync or async requirement.
Core dispatch/context Owned Geo handoff outside public extensions, read-only ctx.geo(), default NoSelection on legacy/unmanaged paths, preserved framework error privacy and egress classification.
Native Geo composition One Off/CloudFront/Supplied selection in AxumRunOptions, capability inspection through finalized options, startup rejection before runner resources, native-only Arc thread bounds.
Checked native ingress Shared CIDR bounds/mapped-peer normalization, separate opaque protected-ALB policy, credential removal before callbacks/error handling while original bytes still count, exactly-once admitted resolution.
Native and portable tests Real no-store listener proof, enabled/disabled/supplied modes, mutation/forgery/cancellation/finalization tests, retained checked-ingress/TLS/lifecycle tests, no-selection WASM contracts.
Docs and CI Native Geo and reconciled diagnostics guides, four compiled Geo examples, explicit feature-state tests, fail-closed dependency graph script, PR trigger for the celld base.
Lockfiles Dependency edges only in root/demo/reusable locks. No selected package version changes. Geo itself adds no third-party runtime dependency.

Issue scope

Closes #432. Implements the CloudFront and synchronous Geo contract under #391. Local GeoIP databases, checked-reader-IP input and database lifecycle are tracked separately in #438 and are not included in this PR.

Trusted Server adoption and jurisdiction/consent interpretation remain separate. Geo is an estimate, not proof of residence, identity or consent. Country-only and unrecognized subdivisions stay unknown to application policy.

Test plan

  • cargo test --locked --workspace --all-targets: 2,115 passed, 6 existing generated-consumer/real-service tests ignored.
  • cargo test --locked -p edgezero-adapter-axum --all-targets --all-features: 422 passed, including mixed AWS/celld startup, preserved lifecycle/EOF tests and 32 hosting tests. Provider service fixtures are loopback-only.
  • Explicit default-off/enabled library suites: 323 disabled and 322 enabled tests passed. Startup-before-resources, bounded parsing, supplied-provider, immutable outcome and exactly-once sentinels executed.
  • Explicit no-default-feature real-hosting suites: 32 enabled and 33 disabled tests passed. Both unavailable-built-in and supplied-without-built-in sentinels passed.
  • cargo clippy --locked --workspace --all-targets --all-features -- -D warnings, disabled-native Clippy, cargo fmt --all -- --check, git diff --check.
  • Workspace feature compilation, minimal native and bindings-only builds, all three adapter WASM target checks.
  • WASM runtime contracts: Fastly 24 tests using Viceroy 0.21.0, Cloudflare 29 using wasm-bindgen-test-runner 0.2.128 and Node 24.12.0, Spin 35 using Wasmtime 48.0.2. Fastly's preparer/closed-lifecycle sentinel also ran explicitly.
  • New Geo and existing native-store dependency scripts. Geo script also has 16 executed failure-case mock checks.
  • Native doc tests: 5 passed. Warnings-denied workspace rustdoc passed. Guide snippets match the compiled examples.
  • App-demo workspace: 54 passed. Reusable fixture-axum checked against its updated lockfile.
  • Docs lint, format and site build. Seven official AWS source pages checked.
  • Manual edgezero serve --adapter axum. Programmatic production-runner and real-listener tests were used instead; no Geo CLI selector is introduced.
  • Actual CloudFront/ALB deployment and downstream policy acceptance. These require separate authorization and are not substitutes for local CI.

Tested implementation is 56eab85f; the later commit records evidence/status only. Exact commands, tested feature sets, timings, local logs and ignored-test limits are in the implementation plan. No failing local gate is excluded.

Trust and acceptance limits

The protected policy declares an externally credential-gated and isolated ALB path. A matching subnet does not authenticate a distribution or prove that its listener rules are correct. EdgeZero does not receive the configured credential value or call AWS to inspect/provision infrastructure.

Production use still requires deployed proof of the credential gate, target isolation, viewer-header replacement including AWS-network missing-subdivision spoofing, value rotation and shared-cache/error-TTL isolation. The guide requires HTTPS, a default-deny ALB, CloudFront origin-facing prefix-list restrictions, private target access, explicitly generated geographic headers and disabled shared caching. No deployment was attempted.

The six existing ignored generated/service harnesses were not counted as passes. Unchanged docs dependencies report seven npm audit vulnerabilities; no unrelated dependency upgrades were attempted. ShellCheck was unavailable; Bash syntax, behavioral failure cases and real graph checks passed.

Checklist

  • Changes follow CLAUDE.md conventions and scoped ownership.
  • No native runtime or AWS SDK dependency added to portable core/WASM graphs. Reconciled TLS fixture dependencies are native test-only.
  • No legacy route-param syntax or application-side geographic-header trust added.
  • Existing store wiring retains typed registries; Geo is separate from stores and PreparedStores.
  • New behavior has constructor, integration, real-listener and WASM tests.
  • No production secrets committed. Checked-in TLS certificates/key and credential strings are public, disposable fixture material only.

@ChristianPavilonis
ChristianPavilonis force-pushed the feature/pluggable-geo-providers branch from 72ceed7 to c8ee48f Compare November 10, 2026 20:15
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review November 10, 2026 23:48

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary

Adds portable Geo outcome types in core, protected CloudFront/ALB source admission in native Axum, and a deployment guide. Requesting changes for one design question (raw geographic headers stay readable to application code) and a CI guard mistake. The rest is cleanup, including two pieces of core plumbing that I confirmed are unneeded by disabling each one and re-running the tests.

Findings

Blocking

  • ❓ Raw CloudFront-Viewer-* headers stay readable from untrusted peers (crates/edgezero-adapter-axum/src/service.rs:473)
  • 🔧 Geo CI steps run in all four host-test legs (.github/workflows/test.yml:189)

Non-blocking

  • ♻️ category_only_json_errors is redundant for every in-tree caller (crates/edgezero-core/src/context.rs:677)

  • ♻️ The pre-dispatch Geo copy is never read (crates/edgezero-core/src/context.rs:783)

  • ♻️ geo_hosting_invalid_policy_never_initializes_or_listens never reaches the runner (crates/edgezero-adapter-axum/tests/production_hosting.rs:2350)

  • 🤔 Does geo-cloudfront pay for itself? (crates/edgezero-adapter-axum/Cargo.toml:40)

  • ⛏ Duplicate dep:getrandom/dep:ipnet in the axum feature (crates/edgezero-adapter-axum/Cargo.toml:22)

  • ⛏ The PR description is stale relative to the head:

    • It cites tested implementation 56eab85f, which is not on this branch; the head is 414e52ae.
    • It names the base as #422 at 5426ea65; the base is now f9b0f20c.
    • The Lockfiles row claims dependency edges in the root/demo/reusable locks; the diff changes no lockfile.
    • The reconciliation section says this diff brings in the #409 checked-ingress contract, but that is already in the base. Here the diff only extracts peer_networks.rs.
    • One section says "Ready for review", another "Draft pending fresh hosted checks".

    Trimming it to the current head's change set and evidence would make the review surface clear.

  • 🌱 Public Geo types will need breaking changes for #438 and VPC origins (crates/edgezero-core/src/geo.rs:280)

  • 🌱 Hidden dispatch_admitted_with_* chain (crates/edgezero-core/src/app.rs:321)

  • 🌱 Guide snippets are hand copies of the doctests: the Rust blocks in docs/guide/native-geo.md (lines 30-174) duplicate the four doctests in crates/edgezero-adapter-axum/src/geo.rs:7-88, and nothing checks that they stay identical. Moving the examples into compiled files and pulling them into the guide with VitePress <<< snippet imports would let the existing doctest CI step cover the guide.

  • 🌱 Kosovo (XK) acceptance check (docs/guide/native-geo.md:218)

📌 Out of Scope

  • Pre-existing macOS-only failures, untouched by this PR (Linux CI is green): the four production_hosting proxy/TLS tests need 127.0.0.2/127.0.0.3 on loopback, and native_aws_startup::production_runner_composes_celld_config_aws_secret_and_local_secret fails with WouldBlock (os error 35) because its fixture's accepted stream inherits non-blocking mode.

CI Status

  • fmt: PASS
  • clippy: PASS
  • tests: PASS (workspace; feature check and Spin wasm32 check also pass)
  • Geo steps run locally: dependency script, core/Axum Geo unit tests, service::tests, doctests, and Geo hosting tests with the built-in disabled and enabled (serial and parallel): PASS
  • GitHub checks at 414e52ae: all green

.await;
}
};
let geo = self.geo.resolve(remote_addr, &parts.headers);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Raw CloudFront-Viewer-* headers stay readable, including from untrusted peers

When a Geo selection is active, both headers are read into GeoInput here, then left on parts.headers. A request from a peer outside the protected ranges gets UntrustedIngress from ctx.geo(), while ctx.headers()["cloudfront-viewer-country"] still carries whatever the client sent. Two lines above, strip_forwarding_headers removes raw forwarding headers unconditionally so application code cannot misread them. Geo has no equivalent, and docs/guide/native-geo.md doesn't tell applications not to read these headers.

Was keeping them intentional? If not, remove both names once they have been consumed. Off stays unchanged:

let geo = self.geo.resolve(remote_addr, &parts.headers);
self.geo.strip_assertions(&mut parts.headers);
// geo.rs
pub(crate) fn strip_assertions(&self, headers: &mut HeaderMap) {
    if matches!(self, Self::Selected { .. }) {
        let _country = headers.remove("cloudfront-viewer-country");
        let _region = headers.remove("cloudfront-viewer-country-region");
    }
}

The fixture's mutated_country == "GB" assertion in production_hosting.rs would change accordingly. If keeping them is deliberate, please add a warning to the guide's "Read the immutable request outcome" section that the raw headers are unauthenticated and ctx.geo() is the only trusted read.

if: matrix.suite == 'workspace'
run: bash scripts/check_native_store_dependency_contract.sh

- name: Geo dependency isolation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 Geo steps run in every host-test matrix leg

Every neighbouring step here is guarded with if: matrix.suite == 'workspace', but the nine new Geo steps (lines 189-219) have no guard. Run 42924133045 shows all nine executed in the workspace, cli, axum and app-demo legs, so the dependency script and four distinct Axum feature builds run four times per push.

Add the guard to each step:

      - name: Geo dependency isolation
        if: matrix.suite == 'workspace'
        run: bash scripts/check_geo_dependency_contract.sh

(or matrix.suite == 'axum' if they are meant to live with the Axum leg).

let bytes = self.buffered_bytes(max, true).await?;
serde_json::from_slice(bytes.as_ref()).map_err(|err| {
if self.body_policy.is_some() {
if self.category_only_json_errors || self.body_policy.is_some() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ category_only_json_errors duplicates the existing body_policy privacy

The only caller that passes FrameworkErrorDetail::CategoryOnly is managed Axum dispatch, and Axum attaches with_buffered_json_policy to every managed body (crates/edgezero-adapter-axum/src/request.rs:84). So self.body_policy.is_some() already makes this path category-only, and the base already asserts "invalid JSON payload" in production_hosting.rs (native_default_errors_json_cap_and_logs_keep_sentinels_private).

I checked this locally by forcing the flag off: all 5 Axum hosting JSON tests and all 436 Axum lib tests still pass. The only failure is app::tests::geo_owned_outcome_survives_public_request_mutation, which builds a body without a JSON policy.

Suggest dropping the flag and the bool half of the (GeoOutcome, bool) plumbing (AdmittedIngress::{request_context, take_request_context, with_request_context} and app.rs:337), and removing the JSON assertions from the Geo test. That also keeps a non-Geo change to core JSON error text out of this PR.

}
}
pub(crate) fn new_pre_dispatch(request: Request, ingress: Option<&AdmittedIngress>) -> Self {
let (geo, category_only_json_errors) = ingress.map_or(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ The pre-dispatch Geo copy is never read

new_pre_dispatch clones the outcome through AdmittedIngress::request_context(), which costs a String allocation for the provider ID on every selected request. PreDispatchRequest has no geo() accessor, and every path that builds the final RequestContext passes Some(ingress) to finish_pre_dispatch, which overwrites the copy from take_request_context().

Locally, defaulting this to (GeoOutcome::NoSelection, false) passes core (1144), Axum lib (436) and the Geo hosting tests, and leaves request_context unused. Either drop it and delete AdmittedIngress::request_context, or, if pre-dispatch hooks should see Geo (design section 3, step 4 says the outcome is stored before request preparers run), add PreDispatchRequest::geo() with a test.

}

#[test]
fn geo_hosting_invalid_policy_never_initializes_or_listens() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ This test never reaches the runner

In geo-invalid-policy mode the fixture passes an empty network list, so ProtectedCloudFrontAlb::new(...)? (tests/fixtures/production_host.rs:1002) fails in main before run_app_with_options_and_initializer is called. The assertions (no INITIALIZED, no bind, no roots) hold by construction. Empty-list rejection is already covered by geo_protected_policy_reuses_network_bounds, and with geo-cloudfront enabled no hosting test exercises runner-side Geo rejection.

Build a valid policy plus a selection that only NativeGeo::validate rejects. This works in both feature states:

let selection = if selected == "geo-reserved-identity" {
    NativeGeoSelection::Supplied {
        policy,
        id: GeoProviderId::cloudfront(),
        provider: Arc::new(FixtureGeoProvider(false)),
    }
} else if selected == "geo-off" {

and rename the mode and test to match.

aws-dynamodb = ["axum", "edgezero-store-aws/dynamodb-kv"]
celld-config = ["axum", "edgezero-store-celld/config"]
celld-kv = ["axum", "edgezero-store-celld/kv"]
geo-cloudfront = ["axum"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Does the geo-cloudfront feature pay for itself?

It gates geo/cloudfront.rs, about 45 non-test lines with no dependencies; check_geo_dependency_contract.sh proves the package set is identical with it on or off. Geo is already off at runtime until with_geo selects it, so the feature is a second off switch. It adds NativeGeoConfigError::UnavailableCloudFront, the cfg-split tests in dev_server.rs and production_hosting.rs, the cfg_attr on the module, and four of the nine new CI steps.

Design section 7 asks for the built-in to be default-off, and NativeGeoSelection::Off already provides that. Consider compiling the built-in under axum and dropping the feature. Non-blocking, since this is a spec-level choice.

"dep:tokio",
"dep:tower",
"dep:futures-util",
"dep:getrandom",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ Duplicate feature entries

axum already lists dep:ipnet and dep:getrandom at lines 27-28 (from the base), so these two additions do nothing. Cargo deduplicates them silently, but the diff reads as if Geo added dependencies. Remove lines 22-23.

///
/// No request, reader IP, service handle or arbitrary headers are available.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeoInput {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 Planned follow-ups will break these public types

GeoInput has public fields and no constructor, and GeoSource/GeoTrustBasis have a single variant each. None are #[non_exhaustive]. #438 (reader-IP input, GeoIP source) and VPC origins (a new trust basis) are already planned, and each will break every provider test that builds GeoInput { .. } and every exhaustive match on these enums. That is allowed at 0.1.0, but worth deciding on purpose. For example: #[non_exhaustive] plus GeoInput::new(country, subdivision) for the input, while keeping GeoStatus/GeoOutcome exhaustive so consumers are forced to handle new statuses.

/// Dispatches with an adapter-owned outcome, independent of public request extensions.
#[doc(hidden)]
#[inline]
pub async fn dispatch_admitted_with_geo(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 Hidden dispatch entry points grow by one per adapter-provided fact

dispatch_admitted -> dispatch_admitted_with_framework_error_detail -> dispatch_admitted_with_geo, each a #[doc(hidden)] pub method. The next adapter-supplied request fact (for example #438's checked reader IP) would add a fourth. A single hidden parameter struct with Default, such as AdmittedDispatch { detail, geo }, would stop the chain.

Comment thread docs/guide/native-geo.md
| `US` and `01` | `Located` with syntax-checked numeric subdivision |
| `US` and `ZZ` | `Located` with a syntax-valid assertion, not verified subdivision membership |
| Subdivision without country | `InvalidAssertion` |
| Unsupported/reserved country, including `XX`, `ZZ`, `UK`, or `XK` | `InvalidAssertion`, no aliases |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 Check what CloudFront sends for Kosovo

AWS documents CloudFront-Viewer-Country only as "ISO 3166-1 alpha-2" and lists no user-assigned codes, so rejecting XK matches the documented contract. MaxMind and CLDR do use XK for Kosovo, though. If CloudFront sends it, every Kosovo viewer becomes InvalidAssertion, indistinguishable from forged input. Suggest adding "observe the country value for a Kosovo client" to this guide's deployment acceptance list.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Axum: add protected CloudFront Geo metadata provider

2 participants