aviso-ecpds
ECPDS (ECMWF Production Data Service) destination authorization plugin for aviso-server.
This crate is consumed by aviso-server as an optional path-dependency behind the ecpds Cargo feature. It lives in its own crate so deployments that don't need ECPDS authorization compile a binary with zero ECPDS code, including zero reqwest calls to ECPDS-specific endpoints.
What it does
When a user calls watch or replay on a stream whose schema declares auth.plugins: ["ecpds"], this crate:
- Extracts the configured
match_keyvalue (e.g.destination) from the request. - Looks up the user's destination list in an in-process single-flight bounded cache (TTL + size cap, eviction via moka's TinyLFU). On miss, queries the configured ECPDS servers in parallel.
- Merges per-server results under the configured
PartialOutagePolicy. - Allows the request iff the requested destination is in the user's authorized list. Otherwise denies with a typed
DenyReason.
notify is never gated.
Public API
- [
config::EcpdsConfig] and [config::PartialOutagePolicy]: serde-deserialized configuration. - [
checker::EcpdsChecker]: falliblenew, asynccheck_access, pluscache_entry_countfor metric sampling. - [
client::EcpdsError]: domain error enum with typed [client::FetchOutcome] (success,http_401,http_403,http_4xx,http_5xx,invalid_response,unreachable) and [client::DenyReason] (DestinationNotInList,MatchKeyMissing). Both have stable Prometheus label strings for theaviso_ecpds_*metrics inaviso-server. - [
cache::CacheOutcome]:Hit,MissCoalesced(waited on a concurrent caller's in-flight fetch), orMissFetched { fetch_outcome }(this caller ran the upstream fetch and surfaces its merged outcome). Returned alongsidecheck_accessresults so the route layer can label cache hit-rate metrics and recordaviso_ecpds_fetch_totalexactly once per upstream call.
This crate is framework-agnostic by design: it does not depend on actix-web, aviso-server, or prometheus. The route layer in aviso-server is responsible for HTTP response shaping and metric recording. This keeps the boundary between "decide" (here) and "expose" (there) clean.
ECPDS API contract assumptions
ECPDS has no public REST API documentation as of this writing. The contract this crate assumes is:
GET <server>/ecpds/v1/destination/list?id=<username>with HTTP Basic Auth (service account credentials).- 200 response body parsed as
{"destinationList": [<record>, ...], "success": "<string>"}. - The
successfield MUST equal exactly"yes". Any other value (including"no","YES","true", etc.) is treated as an upstream-reported failure and surfaces asFetchOutcome::InvalidResponse. This stops a server saying "I failed, here is an empty list" from silently masking the outage inaviso_ecpds_fetch_totaland from contributing an empty allow-list to the merged decision. - Each record is treated as a JSON object. Records with
"active": trueAND a string-valuedtarget_field(default"name") contribute theirtarget_fieldvalue to the user's allow-list. Records whereactiveisfalse, missing, or not a boolean are silently skipped (safe default: deny). Records that lack the configuredtarget_fieldare silently skipped. Both skip cases are logged at debug asauth.ecpds.fetch.skipped_inactive/auth.ecpds.fetch.skipped_record(raise the level viaRUST_LOG=info,aviso_ecpds=debugwhen triaging missing-destination reports). - 4xx/5xx responses are surfaced as
EcpdsError::Http { status, .. }; the merge layer maps them toFetchOutcome::Unauthorized/Forbidden/ClientError/ServerErrorso SREs can distinguish "creds wrong" from "ECPDS down".
These assumptions are pinned by the captured-fixture tests under tests/fixtures/ plus the integration tests in tests/contract.rs. If a real ECPDS environment ever produces a response shape that breaks those tests, the contract has changed and this crate needs an update. That is the single failing test to look for.
Test fixtures
| Fixture | Asserts |
|---|---|
populated_user.json |
Three destinations (one inactive); name field present on each. |
empty_user.json |
Empty destinationList with success: "yes" denies all destinations. |
success_no.json |
success: "no" is treated as a server-side failure: the parser returns FetchOutcome::InvalidResponse so on-call sees the upstream-reported outage in metrics instead of a silent empty allow-list. |
record_missing_target_field.json |
Records lacking target_field are silently skipped, not surfaced as destinations. |
Cargo features
default = [](no features).- The crate has no feature flags of its own. The
ecpdsfeature gating happens on the parent crate (aviso-server), which optionally pulls this crate in.
Why this crate is path-dep, not workspace-member
aviso-server follows the same convention as aviso-validators: domain-specific support crates live as path-dependencies rather than workspace members. The trade-off:
- Default builds (no
--features ecpds) skip the entire dependency tree introduced by this crate (moka,mockito, the extrareqwestconfig); compile time and binary size on the default build do not pay for ECPDS. - The root
Cargo.lockdoes record this crate and its transitive dependencies (it has to:cargoresolves the whole graph regardless of feature gates), so adding the path dep is not lockfile-free. The win is purely on the compile side: optionaldep:plus theecpdsfeature flag meanscargo buildandcargo build --features ecpdsproduce different artifacts from the same lockfile. - The subcrate has its own
Cargo.toml, socargo {build,test,clippy} --manifest-path aviso-ecpds/Cargo.tomlresolves only this crate's dependency graph and runs only this crate's tests, without pulling inaviso-server. Following the standard Rust convention for libraries, the subcrate does not commit its ownCargo.lock; CI re-resolves dependencies fresh on each isolated subcrate run, which is what.github/workflows/ci.yamldoes in the dedicated subcrate test/clippy/fmt steps.
Related documentation
- Authentication > ECPDS Destination Authorization: operator-facing setup guide.
- ECPDS Plugin Runbook: on-call triage.
- Configuration Reference >
ecpds: every config field.
License
Apache-2.0