Skip to content

Support implicit credential lookup plans and shared Fastly secret operations - #444

Open
ChristianPavilonis wants to merge 8 commits into
mainfrom
feature/implicit-secret-lookup-plan
Open

ChristianPavilonis wants to merge 8 commits into
mainfrom
feature/implicit-secret-lookup-plan

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Let typed applications compile active credential lookups without retaining scalar secret-reference leaves.
  • Apply the same lookups and application-native rendering in typed config validate, push and diff. Applications that do not opt in keep the existing behavior.
  • Share existing deployment resolution and secure Fastly create/replace operations with application CLIs.

Companion prerequisite for IABTechLab/trusted-server#1037. This does not implement or prove that project's runtime secret-scoping guarantee.

Changes

Crate / file Change
edgezero-core/app_config Default-compatible implicit lookup and native renderer hooks. Renderer receives effective adapter manifest defaults plus parent environment.
edgezero-cli/config Both typed walkers consume the compiled plan, validate descriptor/path/store/key metadata and declarations, deduplicate identical requests, and reject native collisions. Public effective_manifest_environment helper.
edgezero-adapter / Axum / Spin Optional exact native names retain adapter-native validation instead of validating obsolete raw labels. Existing Spin component resolver is public and reexported by edgezero-cli.
edgezero-adapter-fastly Read-only service/store resolution using existing checked selectors and complete inventories; presence-only metadata listing; explicit POST create / PATCH replace using existing stdin-only curl transport. Raw bytes are Base64-encoded in memory.
edgezero-cli/fastly_secrets Shared context_from_manifest builder and narrow host helper reexports.
tests Real typed validate/push/diff, feature-only serialization, defaults, inactive cleanup, missing metadata/stores, repeated requests, native collisions, manifest environment parity, fictional provider operations and secure subprocess transport.

No deploy descriptors, registry, raw-key fallback, release tags, merging, or production provider calls.

API

AppConfigMeta::implicit_secret_lookups(&self) -> Result<Option<Vec<ImplicitSecretLookup>>, String>

Each lookup has descriptor_id, field_path, store_id, and key. None retains the old leaf walk; Some(vec![]) means no active credentials. The application compiler owns activation and complete descriptor coverage.

AppConfigMeta::render_implicit_secret_name(&self, adapter, lookup, environment: &EnvConfig) -> Result<String, String>

The application supplies its runtime bridge's native name, not EdgeZero's generic mapping. Plans are public metadata, not resolved credentials or authorization.

Host helpers are under edgezero_cli::fastly_secrets: context_from_manifest, resolve_resources, secret_exists, write_secret, injectable _with_transport variants, comparable resource metadata, and FASTLY_SECRET_MAX_BYTES. Explicit create/replace methods preserve the reviewed action on races. Errors do not expose provider response bodies or stderr, writes are not retried, and unconfirmed writes report uncertainty.

Test plan

Rust 1.95.0 on Linux x86_64. Independent target directory, two Cargo jobs, dev/test debug info disabled.

Workspace gates below passed at 5ad74df2. The final 4f645b1e changes only the implicit Fastly physical-store collision check and its regression. The affected CLI all-target tests passed again, including 227 library tests, and its all-features/all-target clippy passed. Other tested inputs remain unchanged.

  • cargo test --workspace --all-targets -- --test-threads=1
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • cargo check --workspace --all-targets --features "fastly cloudflare spin"
  • Focused implicit CLI tests, fictional Fastly provider tests, and existing Spin component-selection regressions.
  • WASM builds and deployed native-provider rehearsals
  • Separate app-demo workspace and docs build

Unverified and blocked

The provider API documentation specifies POST create, PATCH replacement and 64 KiB raw value limits. Host tests verify request construction and subprocess argv/stdin handling with fictional inputs, not real provider acceptance, replacement propagation, read-through-runtime byte preservation, native declaration limits, or config-writer/secret-writer IAM separation. No real credentials or provider mutations were used.

The consuming application's actual renderer/CLI integration and four-adapter runtime proof belong to the companion change. Successful storage never implies a deploy, resource link, activation, retained-state refresh or immediate fleet-wide rotation.

Checklist

  • No Tokio dependencies added
  • Existing default behavior preserved and exercised
  • New behavior has executable host tests
  • No credentials committed
  • Draft only; no merging or releases

@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review November 2, 2026 20:03

@prk-Jr prk-Jr 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 application-compiled implicit secret lookup plans, with native-name rendering and collision checks across typed config validate / push / diff, plus shared host-side Fastly secret operations. I found no blocking issues. Two behaviours are worth confirming (inline), plus a few nits.

😃 Praise

  • The credential handling is careful. The token and secret value reach curl only through the stdin config. Provider bodies and stderr are kept out of errors. Writes use explicit POST (create) or PATCH (replace) and are never retried. A per-key 404 isn't taken as proof of absence, and repeated pagination cursors are caught. The fake-curl subprocess test shows the token stays off argv and leaked stderr is stripped from errors.

Findings

Non-blocking

  • 🤔 Shared store/key across descriptors is always rejected, with misleading messages (crates/edgezero-cli/src/config.rs:1991, :2006). Inline.
  • 🤔 Opting in skips all legacy #[secret] leaf checks (crates/edgezero-cli/src/config.rs:1846). Inline.
  • 🌱 No derive-level opt-in. #[derive(AppConfig)] writes the whole AppConfigMeta impl, so opting in means hand-writing the root impl and losing the derive's compile-time rejections (e.g. rename_all). A derive attribute that forwards to app-supplied functions could come later.
  • ⛏ Stray \' in an untouched doc comment (crates/edgezero-core/src/app_config.rs:141). Inline.
  • ⛏ Read-only Fastly calls report "mutation outcome may be unknown" (crates/edgezero-adapter-fastly/src/secret_operations.rs:246). Inline.
  • ⛏ #[path = "secret_operations.rs"] pub mod secrets name mismatch (crates/edgezero-adapter-fastly/src/cli.rs:7). Inline.
  • 📝 The test-plan section reports gates at 5ad74df2 / 4f645b1e. The head now has two more commits (c24cd994, 47784581). Hosted CI and the local run below cover the current head, so this only needs a description refresh.

CI Status (reviewed head 47784581, local Rust 1.98.1 per .tool-versions)

  • fmt: PASS
  • clippy (--workspace --all-targets --all-features -D warnings): PASS
  • tests (--workspace --all-targets): PASS (1,565 tests)
  • check (--features "fastly cloudflare spin"): PASS
  • Hosted CI: all 28 checks pass, including the WASM jobs.

Not covered here: whether Fastly really accepts these API calls, and the companion application integration (both noted as unverified in the PR).

String::new()
};
let identity = (&lookup.descriptor_id, &lookup.store_id, &lookup.key);
if let Some(previous) = native_owners.insert((namespace, name.clone()), identity)

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.

🤔 Two descriptors can't share one stored credential, and the error calls it a collision

If two lookups with different descriptor_ids (say fictional.pull and fictional.push) both target store default, key ts1_shared, they refer to the same physical secret, but this function always rejects them:

  • Same rendered name: this native_owners check sees different identities and fails with native secret identity collision. I confirmed this with a throwaway test calling check_implicit_adapter for both fastly and axum.
  • Different rendered names: the dedup check at line 2006 matches on store_id + key only (not the descriptor), so it fails with identical secret request has inconsistent native rendering, although the comment says that check is for repeated identical requests. (From reading the code; not run.)

If one credential per descriptor is the intended rule, could it be checked up front with a message that says so (e.g. "store X key Y is requested by more than one descriptor")? If sharing should be allowed, the dedup check should ignore the descriptor when store and key match.

where
C: AppConfigMeta,
{
if let Some(lookups) = typed.implicit_secret_lookups()? {

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.

🤔 Opting in skips every #[secret] leaf check

When implicit_secret_lookups() returns Some, this returns before check_legacy_typed_secrets runs. A hand-written impl that opts in but still has #[secret] fields therefore loses three checks: empty values, whether [stores.secrets] is declared, and whether a store_ref points at a declared id. Runtime code still reads those fields, so the mistake would surface at runtime instead of at config validate. (From reading the code; not run.)

If skipping is deliberate (the PR description says "without retaining scalar secret-reference leaves"), please say so in the implicit_secret_lookups doc. Otherwise, consider still running the legacy checks when C::secret_fields() is non-empty.

/// Every `#[secret]` / `#[secret(store_ref)]` leaf on the struct,
/// including those reached through `#[app_config(nested)]` children,
/// each carrying its full path from this struct's root.
/// each carrying its full path from this struct\'s root.

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 line isn't part of the feature, but it gained a stray backslash (struct\'s). Please revert it to struct's.

path: &str,
body: Option<&str>,
) -> Result<FastlySecretResponse, String> {
transport.request(method, path, body).map_err(|_error| {

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.

⛏ Every caller shares this helper, so read-only calls (secret_exists, resolve_resources) also report "mutation outcome may be unknown" on transport failure, although nothing was written. A neutral message for GETs, or a per-call message, would avoid alarming operators.

)]

/// Narrow host operations for reviewed secret provisioning.
#[path = "secret_operations.rs"]

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 secrets module loads secret_operations.rs via #[path]. Matching the module and file names (e.g. src/cli/secrets.rs, or renaming the module) would make it easier to find.

@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 application-compiled implicit secret lookup plans, applied by typed config validate / push / diff, plus shared host-side Fastly secret operations. Requesting changes for two items. The new Fastly transport test races the rest of the crate's env-mutating tests and can run the real fastly CLI. Five of the new guards can be deleted with every test still passing.

Findings

Blocking

  • 🔧 New fake-curl test uses a different env mutex than the rest of the Fastly test binary: reproduced 2 of 18 failing runs (0 of 12 with the test skipped), including the real fastly compute deploy running from a unit test (crates/edgezero-adapter-fastly/src/secret_operations.rs:626)
  • 🔧 Five new guards are untested, and the push arm of the all-consumers test can't fail: verified by deleting each guard (crates/edgezero-cli/src/config.rs:5708)

Non-blocking

  • ♻️ Fastly special case in the adapter-agnostic CLI: move it to an adapter hook (crates/edgezero-cli/src/config.rs:1983)
  • 🤔 Cloudflare native names are never validated (crates/edgezero-cli/src/config.rs:2016)
  • ♻️ Resource resolution re-implements deploy and skips from_inventories (crates/edgezero-adapter-fastly/src/secret_operations.rs:155)
  • 🤔 Secret operations require all declared Config/KV stores to exist (crates/edgezero-adapter-fastly/src/secret_operations.rs:168)
  • 🤔 Errors drop store identifiers that aren't secret (crates/edgezero-adapter-fastly/src/secret_operations.rs:193)
  • 🤔 A definite 4xx rejection is reported as an unknown outcome (crates/edgezero-adapter-fastly/src/secret_operations.rs:371)
  • ♻️ ImplicitSecretLookup API shape: #[non_exhaustive], three-state return (crates/edgezero-core/src/app_config.rs:94)
  • ♻️ context_from_manifest duplicates run_deploy's context builder (crates/edgezero-cli/src/fastly_secrets.rs:26)
  • 🌱 secret_exists lists the whole store per key (crates/edgezero-adapter-fastly/src/secret_operations.rs:266)
  • ⛏ Glob re-export widens the public API (crates/edgezero-cli/src/fastly_secrets.rs:4)
  • ⛏ Axum native names may start with a digit (crates/edgezero-adapter-axum/src/cli.rs:366)
  • ⛏ base64 dependency ordering (Cargo.toml:46)
  • 🌱 Docs: docs/guide/configuration.md documents #[secret] and hand-written AppConfigMeta, but not the opt-in hooks (implicit_secret_lookups / render_implicit_secret_name) or the edgezero_cli::fastly_secrets helpers. A short section would help the companion's reviewers, and later adopters, find the contract.

The two existing 🤔 threads from prk-Jr (descriptors sharing a credential; opting in skips the legacy #[secret] checks) are still open, and I agree with both.

📌 Out of Scope

  • push_typed_honours_key_override (crates/edgezero-cli/src/config.rs:4721) runs a push without manifest_guard(), while guarded tests such as config.rs:2601 set EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="". This already exists on main and isn't touched by this PR, but it failed 3 times during these runs. Worth a separate issue.

CI Status (reviewed head 47784581, local Rust 1.98.1)

  • fmt: PASS
  • clippy (--workspace --all-targets --all-features -D warnings): PASS
  • tests (--workspace --all-targets): PASS (1,565). The edgezero-adapter-fastly lib suite fails intermittently (2 of 18 runs) because of the first blocking finding.
  • check (--features "fastly cloudflare spin"): PASS
  • WASM checks (spin wasm32-wasip2, fastly wasm32-wasip1, core and cloudflare wasm32-unknown-unknown): PASS
  • Hosted CI: 28/28 checks pass

use edgezero_core::test_env::{EnvOverride, PathPrepend, env_lock};
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt as _;
let _lock = env_lock().lock().expect("env lock");

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 takes a different env mutex than every other env-mutating test in this binary

This test changes PATH and FASTLY_API_TOKEN while holding edgezero_core::test_env::env_lock(). The other 68 env-mutating tests in the edgezero-adapter-fastly lib test binary hold cli::tests::path_mutation_guard() (cli.rs:10362), which is a separate OnceLock<Mutex<()>>. They also set PATH and FASTLY_API_TOKEN (e.g. cli.rs:7133, :7166). The two locks don't exclude each other, so EnvOverride's unsafe { set_var } safety argument ("the caller holds the env lock") does not hold.

Reproduced on this head (cargo test -p edgezero-adapter-fastly --features cli --lib, macOS):

  • 2 of 18 runs failed, each with 62 failures: one root failure plus 61 PoisonError cascades from the poisoned path_mutation_guard.
  • With --skip production_transport_keeps_credentials_off_argv_and_sanitizes_stderr: 0 of 12.
  • Root failure both times: deploy_honours_threaded_manifest_path_and_strips_it_from_the_fastly_argv → "fastly compute deploy failed with status exit status: 1". That test's fake fastly always exits 0. This test's PathPrepend drop restored PATH from under it, so the developer's real /opt/homebrew/bin/fastly compute deploy --service-id SVC1 --non-interactive ran. The opposite interleaving sends this test's write through the real curl to api.fastly.com.

Fix (verified: clippy/fmt clean, 0 of 10 failing runs): give the binary one lock by delegating the existing guard in cli.rs:

use edgezero_core::test_env::{EnvOverride, PathPrepend, env_lock};

fn path_mutation_guard() -> &'static Mutex<()> {
    env_lock()
}

let (dir, manifest, _) = setup_project(PUSH_MANIFEST, &config);
assert!(run_config_validate_typed::<ImplicitConfig>(&args_for(&manifest)).is_err());
assert!(
run_config_push_typed::<ImplicitConfig>(&push_args(&manifest, "axum")).is_err()

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.

🔧 Five new guards can be deleted with the suite still green, and this push assertion can't fail

This push call doesn't set yes = true, so it fails at the consent gate whether or not the implicit preflight rejects the plan. Deleting typed_secret_checks(...) from run_config_push_typed entirely still passes this test. None of the three arms assert on the error text either, so each could pass for an unrelated reason.

I disabled each new guard in turn and ran the affected suites. All of these survived:

Guard Location
Axum native-name charset check edgezero-adapter-axum/src/cli.rs:366
Conflicting (descriptor, path) owner metadata config.rs:1949
"identical secret request has inconsistent native rendering" config.rs:2004
Duplicate logical Fastly store declaration secret_operations.rs:212
Selected Fastly store missing from inventory secret_operations.rs:193

Fix: set push.yes = true (or local + dry_run, as the Fastly test does), assert the expected substring for each bad case, and add one focused test per guard above. The PR checklist says new behavior has executable host tests; right now these branches don't.

// Fastly stores have independent namespaces; the other built-in secret
// bridges flatten their names. Never merge different requests merely
// because their rendered names happen to match.
let namespace = if adapter.name() == "fastly" {

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.

♻️ Fastly-specific rule in the adapter-agnostic CLI

The header of this section (config.rs:1596-1598) says: "defer per-adapter rules to each adapter crate's Adapter trait impl. No if adapter == "spin" branches here." This is that kind of branch. It also means a future adapter with store-scoped secret namespaces would silently get flat-namespace collision semantics.

Suggest a registry hook that Fastly overrides, so the rule (and its test) lives with the adapter:

// edgezero-adapter registry
/// Whether secret names are scoped per physical secret store
/// (true) or share one flat namespace (false).
fn secret_names_scoped_per_store(&self) -> bool {
    false
}
let namespace = if adapter.secret_names_scoped_per_store() {
    environment.store_name_checked("secrets", &lookup.store_id)...
} else {
    String::new()
};

.with_native_name(name),
);
}
adapter.validate_typed_secrets(&entries)

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.

🤔 Cloudflare native names are never validated

Axum, Spin and Fastly now check native_name, but CloudflareCliAdapter still inherits the no-op validate_typed_secrets (edgezero-adapter-cloudflare/src/cli.rs:134). An implicit plan rendered for cloudflare passes config validate / push / diff with /, spaces or a newline in the name. The PR describes a four-adapter companion, so this path will be used.

Suggest a Cloudflare override, or at least a common floor in check_implicit_adapter (non-empty, no whitespace or control characters) that applies to every adapter.

Related: Spin's rejection message still reads "#[secret] field ... Pick a secret reference that conforms" when the entry came from an implicit lookup that has no #[secret] field.

};
let environment = effective_deploy_environment(context)?;
let mut stores = Vec::new();
for (kind, ids, endpoint) in [

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.

♻️ Reuse deploy's resolution rather than re-implementing it

The PR says it shares existing deployment resolution, but this loop duplicates the deploy path (cli.rs:801-815) and drops one of its checks:

  • Deploy builds ResourceInventories::from_inventories(config, kv, secret), which rejects a resource ID that appears under two kinds. That check isn't run here.
  • Deploy resolves via desired_resource_links(&stores, &environment, &inventories), which also rejects duplicate logical IDs and names the store in its errors. Here that's re-done by hand with a post-sort windows(2) scan.

Building ResourceInventories and mapping desired_resource_links's output into FastlyStoreResource would keep secret operations and deploy from drifting apart. It would also cover the two branches here that currently have no test (missing store, duplicate logical ID).

})?;
crate::ensure_adapter_defined("fastly", Some(&loader))?;
let stores = &loader.manifest().stores;
Ok(AdapterDeployContext {

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.

♻️ Second copy of the deploy-context builder

run_deploy (lib.rs:194-228) builds the same DeployStoreIds, variable_defaults, adapter-manifest path and application-manifest path inline. The two copies already differ: deploy's loaded_application_manifest_path rejects a path that isn't a regular file, and this one only canonicalizes. Suggest one helper both call, e.g. fn deploy_context_from_loader(loader: &ManifestLoader, manifest_path: &Path, adapter: &str) -> Result<AdapterDeployContext, String>, so secret operations always target what deploy targets.

/// # Errors
/// Returns an operational error distinct from confirmed absence.
#[inline]
pub fn secret_exists(resource_id: &str, key: &str) -> Result<bool, String> {

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.

🌱 One full store listing per key

Each secret_exists call pages through the whole Secret Store (limit=100) and keeps paging after the key is found. A caller checking N lookups makes N x ceil(secrets/100) GETs. A list_secret_names(resource_id) -> Result<BTreeSet<String>, String> helper (with the same completeness and cursor checks) would let the caller check every key from one listing. Fastly also allows limit=200.

//! Host helpers shared with application-specific secret commands.

pub use edgezero_adapter::registry::{AdapterDeployContext, DeployStoreIds};
pub use edgezero_adapter_fastly::cli::secrets::*;

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 PR describes these as narrow host helper re-exports, but a glob makes any future pub item in edgezero_adapter_fastly::cli::secrets part of edgezero-cli's public API automatically. Listing the items (resolve_resources, secret_exists, write_secret, the _with_transport variants, FastlySecretTransport, FastlySecretResponse, FastlySecretResources, FastlyStoreResource, SecretWriteAction, FASTLY_SECRET_MAX_BYTES) keeps that surface deliberate.

let var = secret_env_var(entry.key_value)
.map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))?;
let var = if let Some(native_name) = &entry.native_name {
if native_name.is_empty()

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 accepts a leading digit (1TOKEN), which shells and most env-file loaders can't set, so the secret would silently be unreachable at runtime. Suggest also requiring the first byte to be A-Z or _. This check currently has no test either (see the coverage comment on config.rs:5708).

Comment thread Cargo.toml
# wasm in the demo Spin build). We only use `Decompressor` and
# `CompressorWriter`, unchanged across the two majors.
brotli = "8"
base64 = "0.22"

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.

⛏ base64 is out of alphabetical order here (after brotli) and in crates/edgezero-adapter-fastly/Cargo.toml (listed before anyhow).

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.

3 participants