diff --git a/.changeset/pnpr-forward-credentials.md b/.changeset/pnpr-forward-credentials.md new file mode 100644 index 0000000000..e876bb9612 --- /dev/null +++ b/.changeset/pnpr-forward-credentials.md @@ -0,0 +1,8 @@ +--- +"@pnpm/installing.deps-installer": minor +"@pnpm/network.auth-header": minor +"@pnpm/pnpr.client": minor +"pnpm": minor +--- + +The pnpr install accelerator now forwards the caller's per-registry credentials on `POST /v1/install`, so it can resolve, verify, and fetch private dependencies from external registries as the caller. The client sends an `Authorization` header identifying itself to the pnpr server plus an `authHeaders` map of the registry tokens (built with `@pnpm/network.auth-header`), and the server threads those credentials through resolution and fetch instead of reaching the registry anonymously. Externally-resolved private content carries no pnpr access policy, so the server gates it per user against the owning registry — serving a cache hit only to a user the registry has cleared — and re-checks access (clearing it on a `401`/`403`) rather than letting the store's possession of the bytes authorize anyone. Packages the registry serves anonymously are classified public once (globally) and then served to everyone without per-user access checks, so a registry that mixes public and private packages doesn't pay the per-user cost for its public ones. diff --git a/installing/deps-installer/package.json b/installing/deps-installer/package.json index 61c020ea68..2b3ff6a402 100644 --- a/installing/deps-installer/package.json +++ b/installing/deps-installer/package.json @@ -98,6 +98,7 @@ "@pnpm/lockfile.utils": "workspace:*", "@pnpm/lockfile.verification": "workspace:*", "@pnpm/lockfile.walker": "workspace:*", + "@pnpm/network.auth-header": "workspace:*", "@pnpm/npm-package-arg": "catalog:", "@pnpm/patching.config": "workspace:*", "@pnpm/pkg-manifest.utils": "workspace:*", diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index 949088f0a3..df05bc1617 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -2346,8 +2346,16 @@ async function installFromPnpmRegistry ( ) } const { fetchFromPnpmRegistry } = await import('@pnpm/pnpr.client') + const { createGetAuthHeaderByURI, getAuthHeadersFromCreds } = await import('@pnpm/network.auth-header') const { StoreIndex } = await import('@pnpm/store.index') const { setImportConcurrency } = await import('@pnpm/worker') + + // Forward the whole credential map (the registries a graph touches + // aren't known up front), so the server attaches the right token per + // URL. `authorization` also identifies the caller to pnpr's gate. + const configByUri = opts.configByUri ?? {} + const forwardedAuthHeaders = getAuthHeadersFromCreds(configByUri) + const pnprAuthorization = createGetAuthHeaderByURI(configByUri)(opts.pnprServer!) // Raise import concurrency for this install only — the pnpr server path has no // concurrent fetching competing for workers. Restore afterwards so we // don't leak a process-wide mutation to other installs (e.g. tests). @@ -2392,6 +2400,10 @@ async function installFromPnpmRegistry ( devDependencies: projectsList ? undefined : manifest.devDependencies, optionalDependencies: projectsList ? undefined : manifest.optionalDependencies, projects: projectsList, + registry: opts.registries?.default, + namedRegistries: opts.namedRegistries, + authHeaders: forwardedAuthHeaders, + authorization: pnprAuthorization, overrides: opts.overrides, minimumReleaseAge: opts.minimumReleaseAge, lockfile: existingLockfile ?? undefined, diff --git a/installing/deps-installer/tsconfig.json b/installing/deps-installer/tsconfig.json index 45fbead776..917b1bbda9 100644 --- a/installing/deps-installer/tsconfig.json +++ b/installing/deps-installer/tsconfig.json @@ -132,6 +132,9 @@ { "path": "../../lockfile/walker" }, + { + "path": "../../network/auth-header" + }, { "path": "../../network/git-utils" }, diff --git a/network/auth-header/src/index.ts b/network/auth-header/src/index.ts index 8b2d6a80d5..d0abddd2f4 100644 --- a/network/auth-header/src/index.ts +++ b/network/auth-header/src/index.ts @@ -4,6 +4,11 @@ import type { RegistryConfig } from '@pnpm/types' import { getAuthHeadersFromCreds } from './getAuthHeadersFromConfig.js' import { removePort } from './helpers/removePort.js' +// Re-exported so callers that need the whole nerf-darted-URI → header map +// (e.g. forwarding every registry credential to the pnpr install +// accelerator) can build it without re-implementing `credsToHeader`. +export { getAuthHeadersFromCreds } + export function createGetAuthHeaderByURI ( configByUri: Record ): (uri: string) => string | undefined { diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index 446e0d9115..3f8a31934e 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -358,6 +358,7 @@ impl InstallArgs { node_linker, lockfile_only, update_seed_policy: UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -468,6 +469,16 @@ async fn install_via_pnpr( dev_dependencies, registry: state.config.registry.clone(), named_registries: state.config.named_registries.clone(), + // Forward the whole credential map: the registries a graph + // touches aren't known up front (scope-routed or tarball-URL + // sub-deps), so the server attaches the right token per URL. + auth_headers: state + .config + .auth_headers + .entries() + .map(|(uri, value)| (uri.to_string(), value.to_string())) + .collect(), + authorization: state.config.auth_headers.for_url(pnpr_server), overrides, lockfile: state.lockfile.clone(), frozen_lockfile: link.frozen_lockfile, @@ -544,6 +555,7 @@ async fn install_via_pnpr( node_linker: link.node_linker, lockfile_only: false, update_seed_policy: UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await diff --git a/pacquet/crates/network/src/auth.rs b/pacquet/crates/network/src/auth.rs index 54856312f3..9029d1625e 100644 --- a/pacquet/crates/network/src/auth.rs +++ b/pacquet/crates/network/src/auth.rs @@ -87,6 +87,13 @@ impl AuthHeaders { AuthHeaders { by_uri, max_parts } } + /// The `(nerf_darted_uri, header_value)` pairs backing this lookup, so + /// a caller can forward the whole set to another process (the pnpr + /// accelerator) and rebuild it with [`Self::from_map`]. + pub fn entries(&self) -> impl Iterator { + self.by_uri.iter().map(|(uri, value)| (uri.as_str(), value.as_str())) + } + /// Resolve an `Authorization` header for `url`, mirroring pnpm's /// `getAuthHeaderByURI`: /// diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index 23d6075a5d..21997584c0 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -124,6 +124,7 @@ where // is the only thing that re-resolves. `update`'s bump is a // separate operation. update_seed_policy: UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await diff --git a/pacquet/crates/package-manager/src/build_resolution_verifiers.rs b/pacquet/crates/package-manager/src/build_resolution_verifiers.rs index 099c750641..30879b5f60 100644 --- a/pacquet/crates/package-manager/src/build_resolution_verifiers.rs +++ b/pacquet/crates/package-manager/src/build_resolution_verifiers.rs @@ -23,7 +23,7 @@ use pacquet_config::{ Config, TrustPolicy, version_policy::{PackageVersionPolicy, VersionPolicyError, create_package_version_policy}, }; -use pacquet_network::ThrottledClient; +use pacquet_network::{AuthHeaders, ThrottledClient}; use pacquet_resolving_npm_resolver::{ CreateNpmResolutionVerifierOptions, PackageMetaCache, create_npm_resolution_verifier, }; @@ -71,6 +71,7 @@ pub fn build_resolution_verifiers( config: &Config, http_client: Arc, meta_cache: Option>, + auth_override: Option>, ) -> Result>, BuildVerifiersError> { let mut verifiers: Vec> = Vec::new(); @@ -119,7 +120,7 @@ pub fn build_resolution_verifiers( .map(|(name, url)| (name.clone(), url.clone())) .collect(), http_client, - auth_headers: Arc::clone(&config.auth_headers), + auth_headers: auth_override.unwrap_or_else(|| Arc::clone(&config.auth_headers)), cache_dir: Some(config.cache_dir.clone()), meta_cache, retry_opts: retry_opts_from_config(config), diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 1f65f5129e..a7c0017690 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -25,7 +25,7 @@ use pacquet_modules_yaml::{ Host, IncludedDependencies, LayoutVersion, Modules, NodeLinker as ModulesNodeLinker, WriteModulesError, read_modules_manifest, write_modules_manifest, }; -use pacquet_network::ThrottledClient; +use pacquet_network::{AuthHeaders, ThrottledClient}; use pacquet_package_manifest::{DependencyGroup, PackageManifest}; use pacquet_reporter::{ ContextLog, LogEvent, LogLevel, PackageManifestLog, PackageManifestMessage, PnpmLog, Reporter, @@ -177,6 +177,12 @@ where /// short-circuit is also bypassed so an `update` that finds newer /// in-range versions isn't skipped as "already up to date". pub update_seed_policy: UpdateSeedPolicy, + /// Per-invocation `Authorization`-header override for resolve/verify; + /// `None` (every local install) uses `config.auth_headers`. The pnpr + /// accelerator threads request-scoped [`AuthHeaders`] here so it + /// resolves a caller's private content without baking per-user auth + /// into the shared `&'static Config`. + pub auth_override: Option>, } /// Error type of [`Install`]. @@ -365,6 +371,7 @@ where node_linker, lockfile_only, update_seed_policy, + auth_override, } = self; // `--lockfile-only` with `lockfile: false` (pnpm's @@ -625,6 +632,7 @@ where Arc::clone(&http_client_arc), Some(Arc::clone(&meta_cache) as Arc), + auth_override.clone(), ) .map_err(InstallError::BuildVerifiers)?; verify_lockfile_resolutions::( @@ -961,6 +969,7 @@ where supported_architectures: supported_architectures.as_ref(), lockfile_only, update_seed_policy, + auth_override, } .run::() .await diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index e1d6dbc7fb..2ddbac4204 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -72,6 +72,7 @@ async fn should_install_dependencies() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -144,6 +145,7 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -191,6 +193,7 @@ async fn should_error_when_frozen_lockfile_and_update_checksums_are_both_set() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -267,6 +270,7 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -336,6 +340,7 @@ async fn npm_alias_dependency_installs_under_alias_key() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -424,6 +429,7 @@ async fn unversioned_npm_alias_defaults_to_latest() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -495,6 +501,7 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -586,6 +593,7 @@ async fn install_emits_pnpm_event_sequence() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -734,6 +742,7 @@ async fn install_writes_modules_yaml() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -838,6 +847,7 @@ async fn install_writes_workspace_state() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1065,6 +1075,7 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1141,6 +1152,7 @@ async fn auto_install_peers_does_not_cascade_optional_peers() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1240,6 +1252,7 @@ async fn auto_install_peers_skips_meta_only_optional_peers() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1375,6 +1388,7 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1476,6 +1490,7 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -1585,6 +1600,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1638,6 +1654,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1733,6 +1750,7 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -1838,6 +1856,7 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -1905,6 +1924,7 @@ async fn ignore_manifest_check_bypasses_manifest_freshness_gate() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -1973,6 +1993,7 @@ async fn frozen_lockfile_errors_when_overrides_drift_from_lockfile() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -2067,6 +2088,7 @@ async fn frozen_lockfile_applies_overrides_to_manifest_before_freshness_check() node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -2177,6 +2199,7 @@ async fn frozen_lockfile_resolves_catalog_protocol_in_overrides_before_freshness node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -2241,6 +2264,7 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -2332,6 +2356,7 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -2442,6 +2467,7 @@ async fn gvs_persists_global_virtual_store_dir_in_modules_yaml_and_context_log() node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -2559,6 +2585,7 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -2642,6 +2669,7 @@ async fn frozen_lockfile_under_gvs_registers_workspace_root_only() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -2845,6 +2873,7 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -2972,6 +3001,7 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -3075,6 +3105,7 @@ async fn frozen_install_propagates_non_optional_fetch_failure() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -3184,6 +3215,7 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -3278,6 +3310,7 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -3375,6 +3408,7 @@ async fn frozen_install_no_optional_keeps_shared_non_optional_snapshot() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -3471,6 +3505,7 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -3562,6 +3597,7 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -3661,6 +3697,7 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -3758,6 +3795,7 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -3859,6 +3897,7 @@ async fn install_rejects_invalid_minimum_release_age_exclude_pattern() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -3962,6 +4001,7 @@ async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -4051,6 +4091,7 @@ async fn fresh_install_writes_pnpm_lock_yaml_with_expected_shape() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4138,6 +4179,7 @@ async fn fresh_install_splits_dev_and_prod_dependency_sections() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4211,6 +4253,7 @@ async fn fresh_install_records_user_written_specifier() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4280,6 +4323,7 @@ async fn fresh_install_lockfile_round_trips_through_load_save_load() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4348,6 +4392,7 @@ async fn fresh_install_with_lockfile_disabled_does_not_write_a_lockfile() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4419,6 +4464,7 @@ async fn fresh_install_also_writes_current_lockfile_under_virtual_store() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4506,6 +4552,7 @@ async fn fresh_install_with_lockfile_disabled_skips_current_lockfile_too() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4571,6 +4618,7 @@ async fn fresh_install_marks_optional_snapshots_in_pnpm_lock_yaml() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4661,6 +4709,7 @@ async fn fresh_install_hoisted_node_linker_records_modules_yaml() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4731,6 +4780,7 @@ async fn fresh_install_refuses_skip_runtimes_before_writing_state() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -4805,6 +4855,7 @@ async fn prefer_frozen_lockfile_takes_frozen_path_when_lockfile_is_fresh() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -4880,6 +4931,7 @@ async fn no_prefer_frozen_lockfile_flag_forces_fresh_resolve() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -4949,6 +5001,7 @@ async fn stale_lockfile_under_no_flag_falls_through_to_fresh_resolve() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -5204,6 +5257,7 @@ async fn frozen_install_short_circuits_when_modules_and_lockfile_are_consistent( lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -5388,6 +5442,7 @@ async fn optimistic_repeat_install_skips_entire_pipeline_when_state_is_fresh() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -5539,6 +5594,7 @@ async fn frozen_lockfile_disables_optimistic_short_circuit() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -5691,6 +5747,7 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing( lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -5772,6 +5829,7 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -5826,6 +5884,7 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -5936,6 +5995,7 @@ async fn fresh_install_applies_package_extensions_to_dependency_manifest() { lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await @@ -6034,6 +6094,7 @@ async fn frozen_lockfile_errors_when_package_extensions_drift_from_lockfile() { node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await; @@ -6114,6 +6175,7 @@ async fn install_with_pnpmfile_reporter( lockfile_only: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await diff --git a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs index a28f2d1c3c..14bf1e8680 100644 --- a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -17,7 +17,7 @@ use pacquet_engine_runtime_deno_resolver::DenoResolver; use pacquet_engine_runtime_node_resolver::NodeResolver; use pacquet_hooks::finder; use pacquet_lockfile::{Lockfile, LockfileResolution, SaveLockfileError}; -use pacquet_network::ThrottledClient; +use pacquet_network::{AuthHeaders, ThrottledClient}; use pacquet_package_manifest::{DependencyGroup, PackageManifest}; use pacquet_reporter::{HookLog, LogEvent, LogLevel, Reporter, Stage, StageLog}; use pacquet_resolving_default_resolver::DefaultResolver; @@ -170,6 +170,9 @@ pub struct InstallWithFreshLockfile<'a, DependencyGroupList> { /// satisfying their manifest range. Drives `pacquet update`'s /// compatible bump; see [`UpdateSeedPolicy`]. pub update_seed_policy: UpdateSeedPolicy, + /// Per-invocation `Authorization`-header override; `None` uses + /// `config.auth_headers`. See [`crate::Install::auth_override`]. + pub auth_override: Option>, } /// Which lockfile-pinned `(name, version)` pairs to *withhold* from the @@ -426,7 +429,12 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList> supported_architectures, lockfile_only, update_seed_policy, + auth_override, } = self; + + // The pnpr override when supplied, else the config's npmrc headers; + // shared by every registry-touching resolver below. + let auth_headers = auth_override.unwrap_or_else(|| Arc::clone(&config.auth_headers)); let is_hoisted = matches!(node_linker, NodeLinker::Hoisted); // Materialise the caller's iterator into a `Vec` so the same // group set can be replayed into both the resolver (consumes @@ -532,7 +540,7 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList> registries, named_registries: merged_named_registries.clone(), http_client: Arc::clone(&http_client_arc), - auth_headers: Arc::clone(&config.auth_headers), + auth_headers: Arc::clone(&auth_headers), meta_cache: Arc::clone(&meta_cache), fetch_locker: Arc::clone(&fetch_locker), picked_manifest_cache: Arc::clone(&picked_manifest_cache), @@ -593,7 +601,7 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList> store_dir, store_index_writer: Some(Arc::clone(&store_index_writer)), mem_cache: Some(Arc::clone(&tarball_mem_cache)), - auth_headers: Arc::clone(&config.auth_headers), + auth_headers: Arc::clone(&auth_headers), retry_opts: crate::retry_config::retry_opts_from_config(config), store_index: store_index.clone(), verify_store_integrity: config.verify_store_integrity, @@ -620,7 +628,7 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList> named_registries: merged_named_registries, registry_names: named_registry_aliases, http_client: Arc::clone(&http_client_arc), - auth_headers: Arc::clone(&config.auth_headers), + auth_headers: Arc::clone(&auth_headers), meta_cache: Arc::clone(&meta_cache), fetch_locker: Arc::clone(&fetch_locker), picked_manifest_cache: Arc::clone(&picked_manifest_cache), diff --git a/pacquet/crates/package-manager/src/remove.rs b/pacquet/crates/package-manager/src/remove.rs index af12960dbf..22d436b821 100644 --- a/pacquet/crates/package-manager/src/remove.rs +++ b/pacquet/crates/package-manager/src/remove.rs @@ -139,6 +139,7 @@ impl<'a> Remove<'a> { // every remaining lockfile pin in the preferred-versions // seed, same as `install` / `add`. update_seed_policy: UpdateSeedPolicy::KeepAll, + auth_override: None, } .run::() .await diff --git a/pacquet/crates/package-manager/src/update.rs b/pacquet/crates/package-manager/src/update.rs index 6f83b214d6..7d8cb5a7f9 100644 --- a/pacquet/crates/package-manager/src/update.rs +++ b/pacquet/crates/package-manager/src/update.rs @@ -392,6 +392,7 @@ impl Update<'_> { node_linker: config.node_linker, lockfile_only, update_seed_policy: seed_policy, + auth_override: None, } .run::() .await diff --git a/pacquet/crates/pnpr-client/Cargo.toml b/pacquet/crates/pnpr-client/Cargo.toml index 09401490df..0b9e75c36c 100644 --- a/pacquet/crates/pnpr-client/Cargo.toml +++ b/pacquet/crates/pnpr-client/Cargo.toml @@ -28,6 +28,8 @@ tracing = { workspace = true } pacquet-testing-utils = { workspace = true } mockito = { workspace = true } pnpr = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } diff --git a/pacquet/crates/pnpr-client/src/lib.rs b/pacquet/crates/pnpr-client/src/lib.rs index 2930671c92..6f9603b461 100644 --- a/pacquet/crates/pnpr-client/src/lib.rs +++ b/pacquet/crates/pnpr-client/src/lib.rs @@ -55,6 +55,14 @@ pub struct InstallOptions<'a> { pub registry: String, /// The client's named-registry aliases. pub named_registries: DepMap, + /// The caller's forwarded upstream credentials, keyed by nerf-darted + /// registry URI, so the server resolves/fetches private content as the + /// caller. Distinct from [`Self::authorization`] (pnpr identity). + pub auth_headers: DepMap, + /// `Authorization` for the pnpr server's own URL (`None` if it needs + /// none): identifies the caller to pnpr's gate and keys the grant + /// table. Distinct from the upstream creds in [`Self::auth_headers`]. + pub authorization: Option, /// The client's `overrides` (selector -> spec) as raw JSON, applied /// at resolve time server-side. pub overrides: Option, @@ -213,6 +221,7 @@ impl PnprClient { "storeIntegrities": store_integrities, "registry": opts.registry, "namedRegistries": opts.named_registries, + "authHeaders": opts.auth_headers, "overrides": opts.overrides, "lockfile": opts.lockfile, "frozenLockfile": opts.frozen_lockfile, @@ -229,8 +238,11 @@ impl PnprClient { "inlineFiles": true, }); - let response = - self.http.post(format!("{}v1/install", self.base_url)).json(&request).send().await?; + let mut post = self.http.post(format!("{}v1/install", self.base_url)).json(&request); + if let Some(authorization) = opts.authorization.as_deref() { + post = post.header("authorization", authorization); + } + let response = post.send().await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); diff --git a/pacquet/crates/pnpr-client/tests/integration.rs b/pacquet/crates/pnpr-client/tests/integration.rs index ec3ec9ff91..a094af2ee2 100644 --- a/pacquet/crates/pnpr-client/tests/integration.rs +++ b/pacquet/crates/pnpr-client/tests/integration.rs @@ -50,6 +50,31 @@ fn deps(entries: [(&str, &str); COUNT]) -> BTreeMap String { + let authority_and_path = url.split("://").nth(1).unwrap_or(url); + let (authority, path) = authority_and_path.split_once('/').unwrap_or((authority_and_path, "")); + let path = path.split(['?', '#']).next().unwrap_or("").trim_matches('/'); + if path.is_empty() { format!("//{authority}/") } else { format!("//{authority}/{path}/") } +} + +/// Register a user with the shared test registry and return its bearer +/// token, so a test can forward it as the caller's upstream credential. +async fn register_token(registry_url: &str, username: &str) -> String { + let body = serde_json::json!({ "name": username, "password": "password123" }); + let response = reqwest::Client::new() + .put(format!("{registry_url}-/user/org.couchdb.user:{username}")) + .json(&body) + .send() + .await + .expect("adduser request"); + assert!(response.status().is_success(), "adduser returned {}", response.status()); + let json: serde_json::Value = response.json().await.expect("adduser response json"); + json["token"].as_str().expect("token in adduser response").to_string() +} + fn options<'a>( store: &'a StoreDir, registry: &str, @@ -61,6 +86,8 @@ fn options<'a>( dev_dependencies: BTreeMap::new(), registry: registry.to_string(), named_registries: BTreeMap::new(), + auth_headers: BTreeMap::new(), + authorization: None, overrides: None, lockfile: None, frozen_lockfile: false, @@ -77,6 +104,91 @@ fn options<'a>( } } +/// The forwarded per-registry credentials and the pnpr-server identity +/// header must travel on the wire: `authHeaders` in the body (so the +/// server resolves/fetches private content as the caller) and +/// `Authorization` on the request (so pnpr's gate + grant table key on +/// the right user). A `mockito` server captures the request and asserts +/// both are present; the canned 500 just short-circuits the client after +/// the match. +#[tokio::test] +async fn forwards_credentials_and_the_identity_header() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/v1/install") + .match_header("authorization", "Bearer pnpr-token") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"authHeaders":{"//npm.acme.test/":"Bearer upstream-token"}}"#.to_string(), + )) + .with_status(500) + .with_body("stop") + .create_async() + .await; + + let client_store = TempDir::new().unwrap(); + let store = StoreDir::new(client_store.path().to_path_buf()); + let client = PnprClient::new(format!("{}/", server.url())); + + let mut opts = options(&store, "https://npm.acme.test/", deps([("@acme/foo", "1.0.0")])); + opts.auth_headers = deps([("//npm.acme.test/", "Bearer upstream-token")]); + opts.authorization = Some("Bearer pnpr-token".to_string()); + + let result = client.install(opts).await; + assert!(result.is_err(), "the canned 500 should surface as an error"); + mock.assert_async().await; +} + +/// End-to-end: the test registry gates `@pnpm.e2e/needs-auth` behind +/// `$authenticated`, so resolving it through the accelerator only works +/// when the caller's upstream token is forwarded and the server fetches +/// the packument + tarball as the caller. +#[tokio::test] +async fn a_forwarded_credential_resolves_a_private_package() { + let registry = TestRegistry::start(); + let token = register_token(®istry.url(), "needs-auth-forwarder").await; + let (pnpr_url, _storage) = start_pnpr().await; + + let client_store = TempDir::new().unwrap(); + let store = StoreDir::new(client_store.path().to_path_buf()); + let client = PnprClient::new(pnpr_url); + + let mut opts = options(&store, ®istry.url(), deps([("@pnpm.e2e/needs-auth", "1.0.0")])); + let mut auth = BTreeMap::new(); + auth.insert(nerf_key(®istry.url()), format!("Bearer {token}")); + opts.auth_headers = auth; + + let outcome = client.install(opts).await.expect("forwarded credential should resolve it"); + let packages = outcome.lockfile.packages.as_ref().expect("lockfile has packages"); + assert!( + packages.keys().any(|key| key.to_string().starts_with("@pnpm.e2e/needs-auth@1.0.0")), + "lockfile should contain the authed package, got: {:?}", + packages.keys().map(ToString::to_string).collect::>(), + ); + assert!(outcome.files_written >= 1, "its files should be materialized"); +} + +/// The same install without a forwarded credential fails: the registry +/// won't serve the gated packument anonymously, so resolution can't read +/// it. +#[tokio::test] +async fn a_private_package_fails_without_a_forwarded_credential() { + let registry = TestRegistry::start(); + let (pnpr_url, _storage) = start_pnpr().await; + + let client_store = TempDir::new().unwrap(); + let store = StoreDir::new(client_store.path().to_path_buf()); + let client = PnprClient::new(pnpr_url); + + let opts = options(&store, ®istry.url(), deps([("@pnpm.e2e/needs-auth", "1.0.0")])); + let Err(PnprClientError::Server(message)) = client.install(opts).await else { + panic!("expected the gated install to fail with a server error"); + }; + assert!( + message.contains("401"), + "expected an auth denial without a forwarded credential, got: {message}", + ); +} + #[tokio::test] async fn resolves_and_downloads_a_package() { let registry = TestRegistry::start(); diff --git a/pacquet/crates/resolving-npm-resolver/src/lib.rs b/pacquet/crates/resolving-npm-resolver/src/lib.rs index ac1bd93dbc..4fae3b5e66 100644 --- a/pacquet/crates/resolving-npm-resolver/src/lib.rs +++ b/pacquet/crates/resolving-npm-resolver/src/lib.rs @@ -69,6 +69,7 @@ pub use pick_package_from_meta::{ RegistryPackageSpec, RegistryPackageSpecType, filter_pkg_metadata_by_publish_date, pick_lowest_version_by_version_range, pick_package_from_meta, pick_version_by_version_range, }; +pub use registry_url::to_registry_url; pub use resolve_from_workspace::{ ResolveFromWorkspaceError, ResolveFromWorkspaceOptions, try_resolve_from_workspace, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d79630d8..67c245ecb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5673,6 +5673,9 @@ importers: '@pnpm/lockfile.walker': specifier: workspace:* version: link:../../lockfile/walker + '@pnpm/network.auth-header': + specifier: workspace:* + version: link:../../network/auth-header '@pnpm/npm-package-arg': specifier: 'catalog:' version: 2.0.0 diff --git a/pnpr/client/src/fetchFromPnpmRegistry.ts b/pnpr/client/src/fetchFromPnpmRegistry.ts index 02723926c1..9bd46f7df7 100644 --- a/pnpr/client/src/fetchFromPnpmRegistry.ts +++ b/pnpr/client/src/fetchFromPnpmRegistry.ts @@ -33,6 +33,24 @@ export interface FetchFromPnpmRegistryOptions { optionalDependencies?: Record /** Multiple projects in a workspace */ projects?: PnprProject[] + /** + * The client's default registry. The server resolves against this + * (and `namedRegistries`) rather than its own configuration. + */ + registry?: string + /** The client's named-registry aliases (`namedRegistries`). */ + namedRegistries?: Record + /** + * The caller's forwarded upstream credentials, keyed by nerf-darted + * registry URI, so the server resolves/fetches private content as the + * caller. Distinct from `authorization` (pnpr identity). + */ + authHeaders?: Record + /** + * `Authorization` for the pnpr server's own URL (`undefined` if none): + * identifies the caller to pnpr's gate and keys the grant table. + */ + authorization?: string /** Overrides */ overrides?: Record /** Node.js version for resolution */ @@ -94,6 +112,9 @@ export async function fetchFromPnpmRegistry ( const requestBody = JSON.stringify({ projects, + registry: opts.registry, + namedRegistries: opts.namedRegistries, + authHeaders: opts.authHeaders, overrides: opts.overrides, nodeVersion: opts.nodeVersion ?? process.version.slice(1), os: process.platform, @@ -108,7 +129,7 @@ export async function fetchFromPnpmRegistry ( inlineFiles: true, }) - const body = await postInstall(opts.registryUrl, requestBody) + const body = await postInstall(opts.registryUrl, requestBody, opts.authorization) // The combined response is `[u32 header length][header JSON][file frames]`. if (body.length < 4) { @@ -179,20 +200,27 @@ const REQUEST_TIMEOUT = 600_000 // 10 minutes — server-side resolution can be * prefix configured on the pnpr server URL (e.g. https://host/pnpr/) is * preserved. */ -async function postInstall (registryUrl: string, body: string): Promise { +async function postInstall (registryUrl: string, body: string, authorization?: string): Promise { const base = registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/` const url = new URL('v1/install', base) const requestFn = url.protocol === 'https:' ? https.request : http.request + const headers: http.OutgoingHttpHeaders = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'Accept-Encoding': 'gzip', + } + // Identify the caller to the pnpr server's access gate so protected + // packages resolve and the per-user grant table keys on the right user. + if (authorization != null) { + headers.Authorization = authorization + } + return new Promise((resolve, reject) => { const req = requestFn(url, { method: 'POST', timeout: REQUEST_TIMEOUT, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - 'Accept-Encoding': 'gzip', - }, + headers, }, (res) => { const chunks: Buffer[] = [] res.on('data', (chunk: Buffer) => chunks.push(chunk)) diff --git a/pnpr/crates/pnpr/src/config.rs b/pnpr/crates/pnpr/src/config.rs index 1733782490..d6211e7cd1 100644 --- a/pnpr/crates/pnpr/src/config.rs +++ b/pnpr/crates/pnpr/src/config.rs @@ -85,6 +85,11 @@ pub struct Config { /// installs at startup. Sourced from the YAML `log:` object /// (Verdaccio 6+ shape). Defaults to pretty/info. pub logs: LogConfig, + /// How long the install accelerator keeps a per-user access grant + /// before re-verifying. `None` (default) is permanent (revocation + /// relies on clear-on-discovery). YAML `installAccelerator.grantTtl` + /// (seconds). + pub install_accelerator_grant_ttl: Option, } /// Auth-related runtime configuration. Built from the YAML @@ -290,6 +295,19 @@ struct ConfigFile { /// intentionally not accepted. #[serde(default)] log: Option, + /// pnpr-only block tuning the install accelerator. Absent on a + /// stock verdaccio config (silently ignored there, like the other + /// keys verdaccio doesn't share). + #[serde(default, rename = "installAccelerator")] + install_accelerator: Option, +} + +/// The YAML `installAccelerator:` block. +#[derive(Debug, Default, Deserialize)] +struct InstallAcceleratorFile { + /// `grantTtl` in seconds. Absent ⇒ permanent grants. + #[serde(default, rename = "grantTtl")] + grant_ttl: Option, } /// The YAML `log:` object. Mirrors verdaccio 6's logger config. @@ -362,6 +380,7 @@ impl Config { policies: PackagePolicies::registry_mock_defaults(), auth: AuthConfig::default(), logs: LogConfig::default(), + install_accelerator_grant_ttl: None, } } @@ -378,6 +397,7 @@ impl Config { policies: PackagePolicies::registry_mock_defaults(), auth: AuthConfig::default(), logs: LogConfig::default(), + install_accelerator_grant_ttl: None, } } @@ -497,6 +517,10 @@ impl Config { policies, auth, logs, + install_accelerator_grant_ttl: file + .install_accelerator + .and_then(|block| block.grant_ttl) + .map(Duration::from_secs), }) } diff --git a/pnpr/crates/pnpr/src/install_accelerator.rs b/pnpr/crates/pnpr/src/install_accelerator.rs index 88ddf78a47..e81e98ec58 100644 --- a/pnpr/crates/pnpr/src/install_accelerator.rs +++ b/pnpr/crates/pnpr/src/install_accelerator.rs @@ -16,10 +16,11 @@ //! followed by the binary file frames). One round trip //! ([pnpm/pnpm#12165](https://github.com/pnpm/pnpm/issues/12165)). //! -//! Files are bound to access: every package whose bytes are served is -//! checked against pnpr's `packages:` policy first -//! ([`deny_unauthorized_packages`]), so a content-addressed digest is -//! never a bearer capability for a package the caller can't read. +//! Files are bound to access ([`authorize_served_packages`]): a +//! content-addressed digest is never a bearer capability. Anonymous +//! content is checked against pnpr's own `packages:` policy; content +//! fetched with the caller's forwarded credentials is gated per user +//! against the owning registry. //! //! The client's `registry`, `namedRegistries`, `overrides`, and the //! verification policy (`minimumReleaseAge`, `trustPolicy`, ...) drive @@ -29,12 +30,15 @@ //! non-frozen → reuse-and-update). A multi-project workspace is resolved //! by reconstructing the workspace on disk (root manifest + //! `pnpm-workspace.yaml` + member manifests) and letting pacquet's -//! install path discover and resolve every importer. **Deferred:** -//! auth/credential forwarding (so private registries resolve). Responses -//! are buffered rather than truly streamed. +//! install path discover and resolve every importer. The client also +//! forwards its per-registry credentials, so private dependencies resolve +//! and fetch as the caller. Responses are buffered rather than truly +//! streamed. mod diff; +mod grant_table; mod protocol; +mod public_packages; mod resolve; mod verdict_cache; @@ -46,6 +50,7 @@ use std::{ io::Write as _, path::PathBuf, sync::{Arc, Mutex, OnceLock}, + time::Duration, }; use crate::{ @@ -64,13 +69,16 @@ use indexmap::IndexMap; use pacquet_config::Config as PacquetConfig; use pacquet_lockfile::Lockfile; use pacquet_lockfile_verification::{collect_resolution_policy_violations, hash_lockfile}; -use pacquet_network::ThrottledClient; +use pacquet_network::{AuthHeaders, ThrottledClient}; use pacquet_package_manager::build_resolution_verifiers; -use pacquet_resolving_npm_resolver::{InMemoryPackageMetaCache, PackageMetaCache}; +use pacquet_resolving_npm_resolver::{InMemoryPackageMetaCache, PackageMetaCache, to_registry_url}; use pacquet_resolving_resolver_base::ResolutionVerifier; use pacquet_store_dir::{StoreDir, StoreIndex}; -use self::{protocol::InstallRequest, verdict_cache::VerdictCache}; +use self::{ + grant_table::GrantTable, protocol::InstallRequest, public_packages::PublicPackages, + verdict_cache::VerdictCache, +}; /// Per-server engine backing the pnpr install endpoints: it holds the /// store, cache, and HTTP client used to resolve a client's project and @@ -96,6 +104,18 @@ pub(crate) struct InstallAccelerator { /// only if the database couldn't be opened — verification then runs /// every time (uncached) rather than failing the server. verdict_cache: Option, + /// Per-`(user, name@version)` access grants for externally-resolved + /// private content. `None` if the DB couldn't be opened (every such + /// package then re-verifies uncached). See [`GrantTable`]. + grant_table: Option, + /// Global set of anonymously-readable package names, so a public + /// package isn't gated per user. `None` if the DB couldn't be opened. + /// See [`PublicPackages`]. + public_packages: Option, + /// How long a grant (or public classification) stays valid. `None` + /// (the default) is permanent, leaving revocation to + /// clear-on-discovery; a TTL lets it bite already-seen versions. + grant_ttl: Option, } impl InstallAccelerator { @@ -115,12 +135,17 @@ impl InstallAccelerator { let _ = std::fs::create_dir_all(&store_dir); let _ = std::fs::create_dir_all(&cache_dir); let verdict_cache = VerdictCache::open(&cache_dir.join("lockfile-verdicts.sqlite")).ok(); + let grant_table = GrantTable::open(&cache_dir.join("install-grants.sqlite")).ok(); + let public_packages = PublicPackages::open(&cache_dir.join("public-packages.sqlite")).ok(); InstallAccelerator { store_dir: StoreDir::new(store_dir), cache_dir, client: Arc::new(ThrottledClient::new_for_installs()), configs: Mutex::new(HashMap::new()), verdict_cache, + grant_table, + public_packages, + grant_ttl: config.install_accelerator_grant_ttl, } } @@ -182,8 +207,8 @@ impl InstallAccelerator { /// Handle `POST /v1/install`. `identity` is the resolved caller; the /// store's possession of a package's bytes is not a capability to read -/// them, so every served package is checked against `policies` — see -/// [`deny_unauthorized_packages`]. +/// them, so every served package is authorized first — see +/// [`authorize_served_packages`]. pub(crate) async fn handle_install( runtime: &InstallAccelerator, policies: &PackagePolicies, @@ -198,6 +223,13 @@ pub(crate) async fn handle_install( // Resolve against the client's registries, not the server's own. let config = runtime.config_for(&request); + // The caller's forwarded upstream credentials, threaded through + // resolve/verify/fetch but kept out of the interned `config` so it + // never leaks a `&'static Config` per user. + let request_auth = Arc::new(AuthHeaders::from_map( + request.auth_headers.iter().map(|(uri, value)| (uri.clone(), value.clone())).collect(), + )); + // Verify the *input* lockfile under the client's policy before // resolving ([pnpm/pnpm#12139](https://github.com/pnpm/pnpm/issues/12139)). // The client skips its own `verifyLockfileResolutions` whenever a @@ -209,7 +241,8 @@ pub(crate) async fn handle_install( // pick-time gate (the policy is wired into `config`). if !request.trust_lockfile && let Some(input_lockfile) = request.lockfile.as_ref() - && let Err(failure) = verify_input_lockfile(runtime, config, input_lockfile).await + && let Err(failure) = + verify_input_lockfile(runtime, config, &request_auth, input_lockfile).await { return match failure { VerifyFailure::Internal(response) => response, @@ -217,13 +250,17 @@ pub(crate) async fn handle_install( }; } - let lockfile = match resolve::resolve(config, &runtime.client, &request).await { + let lockfile = match resolve::resolve(config, &runtime.client, &request, &request_auth).await { Ok(lockfile) => lockfile, Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), }; let packages = resolve::collect_packages(&lockfile, &config.registry); + // `pkg_id`s fetched from upstream this request: the registry accepted + // the caller's token for each, so the gate treats them as proven. + let mut freshly_fetched: HashSet = HashSet::new(); + // `--lockfile-only`: pnpm resolves and writes the lockfile but // fetches nothing and links nothing. Skip the tarball fetch + the // file-level diff and return just the lockfile; the client writes it @@ -236,8 +273,9 @@ pub(crate) async fn handle_install( stats: diff::Stats { total_packages: packages.len() as u64, ..diff::Stats::default() }, } } else { - if let Err(err) = resolve::fetch_uncached(config, &runtime.client, &packages).await { - return json_error(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()); + match resolve::fetch_uncached(config, &runtime.client, &request_auth, &packages).await { + Ok(fetched) => freshly_fetched = fetched, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()), } let store = match StoreIndex::open_readonly_in(&config.store_dir) { @@ -259,7 +297,17 @@ pub(crate) async fn handle_install( } }; - if let Some(denied) = deny_unauthorized_packages(policies, &identity, &result.package_index) { + if let Some(denied) = authorize_served_packages( + runtime, + policies, + &identity, + &request, + &request_auth, + &freshly_fetched, + &result.package_index, + ) + .await + { return denied; } @@ -279,34 +327,63 @@ fn stats_json(stats: &diff::Stats) -> serde_json::Value { }) } -/// Deny the install when the caller may not read a package whose files -/// are about to be served. A content-addressed digest is shared across -/// packages and reveals nothing about access, so possession of a -/// package's bytes in the store is never a capability to receive them: -/// every served package is checked against pnpr's own `packages:` policy -/// — the same decision `serve_packument` / `serve_tarball` make, in -/// process, with no network round trip. Returns the denial response (401 -/// for an anonymous caller who could authenticate, 403 for an -/// authenticated caller outside the allowed set) or `None` when every -/// served package is readable. -/// -/// This authorizes against pnpr's own surface, which is the authority for -/// everything the store can hold today: pnpr fetches anonymously (no -/// credential forwarding yet), so cached content is either pnpr-hosted or -/// publicly fetchable. When credential forwarding lands, packages the -/// client resolved from *external* registries under its own token become -/// reachable, and those carry no pnpr policy — their access must then be -/// re-verified per caller against the owning registry (with a TTL'd -/// verdict cache), which this local check does not cover. -fn deny_unauthorized_packages( +/// Authorize every served package before its files leave the store (a +/// shared content digest is never a read capability), dispatched by +/// whether a forwarded credential was used to fetch it: such packages are +/// gated per user against the owning registry +/// ([`authorize_upstream_package`]); the rest by pnpr's local `packages:` +/// policy ([`deny_local_policy`]). Returns the first denial, or `None`. +async fn authorize_served_packages( + runtime: &InstallAccelerator, policies: &PackagePolicies, identity: &Identity, + request: &InstallRequest, + request_auth: &AuthHeaders, + freshly_fetched: &HashSet, served: &[diff::PackageIndexEntry], ) -> Option { - let mut checked: HashSet<&str> = HashSet::new(); + // The default registry pnpr resolved against (what `collect_packages` + // / `fetch_uncached` built every tarball URL from). Per-scope external + // registries are a future refinement. + let registry = request.registry.as_deref().unwrap_or("https://registry.npmjs.org/"); + + let mut local_pkg_ids: Vec<&str> = Vec::new(); for entry in served { let Some(name) = package_name(&entry.pkg_id) else { continue }; - // Access is decided per package name, so evaluate each name once. + let pkg_url = to_registry_url(registry, name); + if request_auth.for_url(&pkg_url).is_none() { + local_pkg_ids.push(entry.pkg_id.as_str()); + continue; + } + if let Some(denied) = authorize_upstream_package( + runtime, + identity, + request_auth, + freshly_fetched, + registry, + name, + &entry.pkg_id, + ) + .await + { + return Some(denied); + } + } + + deny_local_policy(policies, identity, local_pkg_ids.into_iter()) +} + +/// Deny when the caller may not read a package gated by pnpr's own +/// `packages:` policy. 401 for anonymous, 403 for an authenticated caller +/// outside the allowed set; `None` when every name is readable. +fn deny_local_policy<'a>( + policies: &PackagePolicies, + identity: &Identity, + pkg_ids: impl Iterator, +) -> Option { + let mut checked: HashSet<&str> = HashSet::new(); + for pkg_id in pkg_ids { + let Some(name) = package_name(pkg_id) else { continue }; if !checked.insert(name) { continue; } @@ -321,6 +398,125 @@ fn deny_unauthorized_packages( None } +/// Authorize one upstream-as-authority package: the owning registry, not +/// pnpr, decides. Known-public, freshly fetched, or already granted → +/// allow (recording a grant where applicable); otherwise probe the +/// registry anonymously (a `2xx` records it public globally) then +/// re-verify with the caller's token (`2xx` grants, `401`/`403` clears the +/// caller's grants and denies). Grants key on an identified user; the +/// global public set benefits anonymous callers too. See the body's +/// branches and the module tests for each path. +async fn authorize_upstream_package( + runtime: &InstallAccelerator, + identity: &Identity, + request_auth: &AuthHeaders, + freshly_fetched: &HashSet, + registry: &str, + name: &str, + pkg_id: &str, +) -> Option { + // Public content needs no per-user gating, so it never reaches the + // grant table or an upstream round trip once classified. + if let Some(public) = runtime.public_packages.as_ref() + && public.is_public(name, runtime.grant_ttl) + { + return None; + } + + let user = match identity { + Identity::User { username } => Some(username.as_str()), + Identity::Anonymous => None, + }; + let grants = || user.zip(runtime.grant_table.as_ref()); + + // The cold fetch this request already proved access: the upstream + // accepted the caller's forwarded token. + if freshly_fetched.contains(pkg_id) { + if let Some((user, table)) = grants() { + table.record(user, pkg_id); + } + return None; + } + + if let Some((user, table)) = grants() + && table.is_granted(user, pkg_id, runtime.grant_ttl) + { + return None; + } + + // Classify before gating per user: a package the registry serves + // anonymously is public — record it globally so no one probes it + // again. Only a token-gated package takes the per-user path below. + if let UpstreamAccess::Authorized = + probe_upstream_access(&runtime.client, None, registry, name).await + { + if let Some(public) = runtime.public_packages.as_ref() { + public.record(name); + } + return None; + } + + match probe_upstream_access(&runtime.client, Some(request_auth), registry, name).await { + UpstreamAccess::Authorized => { + if let Some((user, table)) = grants() { + table.record(user, pkg_id); + } + None + } + UpstreamAccess::Denied => { + if let Some((user, table)) = grants() { + table.clear_package(user, name); + } + Some(json_error(StatusCode::FORBIDDEN, &format!("not authorized to access {name:?}"))) + } + UpstreamAccess::Unknown => Some(json_error( + StatusCode::BAD_GATEWAY, + &format!("could not verify access to {name:?}"), + )), + } +} + +/// Outcome of an upstream access probe. +enum UpstreamAccess { + /// The upstream served the package's packument for the probe. + Authorized, + /// The upstream returned `401`/`403`. + Denied, + /// The upstream was unreachable or returned some other status; access + /// can't be decided. + Unknown, +} + +/// Probe whether `name` is readable from `registry` by fetching its +/// (abbreviated) packument. `auth` set attaches the caller's credential +/// (a re-verify); `auth` `None` is anonymous (a public/private check). +async fn probe_upstream_access( + client: &ThrottledClient, + auth: Option<&AuthHeaders>, + registry: &str, + name: &str, +) -> UpstreamAccess { + let url = to_registry_url(registry, name); + let guard = client.acquire_for_url(&url).await; + let mut request = guard.get(&url).header("accept", "application/vnd.npm.install-v1+json"); + if let Some(value) = auth.and_then(|auth| auth.for_url(&url)) { + request = request.header("authorization", value); + } + match request.send().await { + Ok(response) => { + let status = response.status().as_u16(); + if (200..300).contains(&status) { + UpstreamAccess::Authorized + } else if status == 401 || status == 403 { + UpstreamAccess::Denied + } else { + UpstreamAccess::Unknown + } + } + Err(_) => UpstreamAccess::Unknown, + } +} + /// The package name from a `name@version` package id, tolerating a /// leading scope `@` (`@scope/foo@1.0.0` → `@scope/foo`). fn package_name(pkg_id: &str) -> Option<&str> { @@ -421,6 +617,7 @@ enum VerifyFailure { async fn verify_input_lockfile( runtime: &InstallAccelerator, config: &'static PacquetConfig, + auth_headers: &Arc, lockfile: &Lockfile, ) -> Result<(), VerifyFailure> { // A fresh per-request packument cache shared with the verifier; the @@ -431,6 +628,7 @@ async fn verify_input_lockfile( config, Arc::clone(&runtime.client), Some(meta_cache as Arc), + Some(Arc::clone(auth_headers)), ) .map_err(|err| { VerifyFailure::Internal(json_error(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())) diff --git a/pnpr/crates/pnpr/src/install_accelerator/grant_table.rs b/pnpr/crates/pnpr/src/install_accelerator/grant_table.rs new file mode 100644 index 0000000000..a347669677 --- /dev/null +++ b/pnpr/crates/pnpr/src/install_accelerator/grant_table.rs @@ -0,0 +1,108 @@ +//! Per-`(user, name@version)` allow-list gating externally-resolved +//! private content ([pnpm/pnpm#12184](https://github.com/pnpm/pnpm/issues/12184)): +//! the store dedups the bytes globally, but possession must not authorize +//! a user the owning registry never cleared. Backed by SQLite (WAL) like +//! [`super::verdict_cache::VerdictCache`]; every method is best-effort (a +//! DB error never fails the request, at worst one extra re-verify). + +use std::{ + path::Path, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use rusqlite::Connection; + +/// Soft cap on stored grants; the oldest rows (by `granted_at_ms`) are +/// evicted past this. +const MAX_ROWS: i64 = 100_000; + +/// Concurrency-safe store of per-`(user, name@version)` access grants. +pub(crate) struct GrantTable { + conn: Mutex, +} + +impl GrantTable { + /// Open (creating if needed) the grant database at `path`. + pub(crate) fn open(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.busy_timeout(Duration::from_secs(5))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS grants ( + user TEXT NOT NULL, + pkg TEXT NOT NULL, + granted_at_ms INTEGER NOT NULL, + PRIMARY KEY (user, pkg) + );", + )?; + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Whether `(user, pkg)` holds a grant still within `ttl` (`None` = + /// permanent). `pkg` is the `name@version` package id. + pub(crate) fn is_granted(&self, user: &str, pkg: &str, ttl: Option) -> bool { + let conn = self.conn.lock().expect("grant table poisoned"); + let granted_at: Option = conn + .query_row( + "SELECT granted_at_ms FROM grants WHERE user = ?1 AND pkg = ?2", + rusqlite::params![user, pkg], + |row| row.get(0), + ) + .ok(); + let Some(granted_at) = granted_at else { + return false; + }; + match ttl { + None => true, + Some(ttl) => now_ms().saturating_sub(granted_at) <= ttl.as_millis() as i64, + } + } + + /// Record (or refresh) a grant for `(user, pkg)`. Best-effort. + pub(crate) fn record(&self, user: &str, pkg: &str) { + let now = now_ms(); + let conn = self.conn.lock().expect("grant table poisoned"); + let _ = conn.execute( + "INSERT INTO grants (user, pkg, granted_at_ms) VALUES (?1, ?2, ?3) + ON CONFLICT(user, pkg) DO UPDATE SET granted_at_ms = excluded.granted_at_ms", + rusqlite::params![user, pkg, now], + ); + evict_overflow(&conn); + } + + /// Clear-on-discovery: drop every grant `user` holds for `name`, + /// across all versions (matched by the `name@` prefix, since `pkg` is + /// `name@version`). Best-effort. + pub(crate) fn clear_package(&self, user: &str, name: &str) { + let with_at = format!("{name}@"); + let prefix_len = with_at.chars().count() as i64; + let conn = self.conn.lock().expect("grant table poisoned"); + let _ = conn.execute( + "DELETE FROM grants WHERE user = ?1 AND substr(pkg, 1, ?2) = ?3", + rusqlite::params![user, prefix_len, with_at], + ); + } +} + +/// Trim the oldest rows past [`MAX_ROWS`], ordered by `granted_at_ms`. +fn evict_overflow(conn: &Connection) { + let _ = conn.execute( + "DELETE FROM grants WHERE rowid IN ( + SELECT rowid FROM grants + ORDER BY granted_at_ms DESC + LIMIT -1 OFFSET ?1 + )", + rusqlite::params![MAX_ROWS], + ); +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests; diff --git a/pnpr/crates/pnpr/src/install_accelerator/grant_table/tests.rs b/pnpr/crates/pnpr/src/install_accelerator/grant_table/tests.rs new file mode 100644 index 0000000000..f04734abed --- /dev/null +++ b/pnpr/crates/pnpr/src/install_accelerator/grant_table/tests.rs @@ -0,0 +1,74 @@ +use std::{thread::sleep, time::Duration}; + +use tempfile::TempDir; + +use super::GrantTable; + +fn open() -> (GrantTable, TempDir) { + let dir = TempDir::new().expect("tempdir"); + let table = GrantTable::open(&dir.path().join("grants.sqlite")).expect("open grant table"); + (table, dir) +} + +#[test] +fn records_and_reads_a_grant() { + let (table, _dir) = open(); + assert!(!table.is_granted("alice", "@acme/foo@1.0.0", None)); + table.record("alice", "@acme/foo@1.0.0"); + assert!(table.is_granted("alice", "@acme/foo@1.0.0", None)); + // A grant is per-user and per-version. + assert!(!table.is_granted("bob", "@acme/foo@1.0.0", None)); + assert!(!table.is_granted("alice", "@acme/foo@2.0.0", None)); +} + +#[test] +fn clear_package_drops_every_version_for_that_user_only() { + let (table, _dir) = open(); + table.record("alice", "@acme/foo@1.0.0"); + table.record("alice", "@acme/foo@2.0.0"); + table.record("alice", "@acme/bar@1.0.0"); + table.record("bob", "@acme/foo@1.0.0"); + + table.clear_package("alice", "@acme/foo"); + + assert!(!table.is_granted("alice", "@acme/foo@1.0.0", None)); + assert!(!table.is_granted("alice", "@acme/foo@2.0.0", None)); + // A different package the same user holds is untouched. + assert!(table.is_granted("alice", "@acme/bar@1.0.0", None)); + // Another user's grant for the same package is untouched. + assert!(table.is_granted("bob", "@acme/foo@1.0.0", None)); +} + +#[test] +fn clear_package_does_not_prefix_match_a_sibling_name() { + let (table, _dir) = open(); + // `foo` must not clear `foo-bar` — the `@`-delimited prefix guards it. + table.record("alice", "foo@1.0.0"); + table.record("alice", "foo-bar@1.0.0"); + table.clear_package("alice", "foo"); + assert!(!table.is_granted("alice", "foo@1.0.0", None)); + assert!(table.is_granted("alice", "foo-bar@1.0.0", None)); +} + +#[test] +fn a_ttl_expires_an_old_grant() { + let (table, _dir) = open(); + table.record("alice", "foo@1.0.0"); + // Still valid under a generous TTL. + assert!(table.is_granted("alice", "foo@1.0.0", Some(Duration::from_secs(60)))); + // Expired under a zero TTL once any time has passed. + sleep(Duration::from_millis(5)); + assert!(!table.is_granted("alice", "foo@1.0.0", Some(Duration::from_millis(1)))); +} + +#[test] +fn grants_persist_across_reopen() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("grants.sqlite"); + { + let table = GrantTable::open(&path).expect("open"); + table.record("alice", "foo@1.0.0"); + } + let reopened = GrantTable::open(&path).expect("reopen"); + assert!(reopened.is_granted("alice", "foo@1.0.0", None)); +} diff --git a/pnpr/crates/pnpr/src/install_accelerator/protocol.rs b/pnpr/crates/pnpr/src/install_accelerator/protocol.rs index 5c04ea471a..573541544a 100644 --- a/pnpr/crates/pnpr/src/install_accelerator/protocol.rs +++ b/pnpr/crates/pnpr/src/install_accelerator/protocol.rs @@ -53,6 +53,13 @@ pub struct InstallRequest { /// `namedRegistries`). #[serde(default)] pub named_registries: BTreeMap, + /// The caller's forwarded upstream credentials so the server resolves + /// and fetches private content as the caller. Keyed by nerf-darted + /// registry URI with ready-to-send values, the shape + /// [`pacquet_network::AuthHeaders::from_map`] consumes. Distinct from + /// the request's HTTP `Authorization` header (pnpr identity). + #[serde(default)] + pub auth_headers: BTreeMap, /// The client's `overrides` (selector -> spec), applied at resolve /// time. Kept as raw JSON; reconstructed into pacquet's override map /// server-side. diff --git a/pnpr/crates/pnpr/src/install_accelerator/public_packages.rs b/pnpr/crates/pnpr/src/install_accelerator/public_packages.rs new file mode 100644 index 0000000000..47611e8366 --- /dev/null +++ b/pnpr/crates/pnpr/src/install_accelerator/public_packages.rs @@ -0,0 +1,95 @@ +//! Global set of **anonymously-readable** package names, so the per-user +//! grant table never gates a public package +//! ([pnpm/pnpm#12184](https://github.com/pnpm/pnpm/issues/12184)). A +//! forwarded token matching a registry only means pnpr fetched a package +//! with it, not that the package is private; in a mixed proxy that would +//! gate public content per user too. Populated lazily by one anonymous +//! probe per name, so a public package costs one round trip fleet-wide. +//! SQLite (WAL) like [`super::grant_table::GrantTable`]; best-effort. + +use std::{ + path::Path, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use rusqlite::Connection; + +/// Soft cap on classified names; the oldest rows (by `classified_at_ms`) +/// are evicted past this. +const MAX_ROWS: i64 = 100_000; + +/// Concurrency-safe set of anonymously-readable package names. +pub(crate) struct PublicPackages { + conn: Mutex, +} + +impl PublicPackages { + /// Open (creating if needed) the classification database at `path`. + pub(crate) fn open(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.busy_timeout(Duration::from_secs(5))?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS public_packages ( + name TEXT PRIMARY KEY, + classified_at_ms INTEGER NOT NULL + );", + )?; + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Whether `name` was classified anonymously-readable within `ttl` + /// (`None` = permanent). Keyed by name (readability is per-name). + pub(crate) fn is_public(&self, name: &str, ttl: Option) -> bool { + let conn = self.conn.lock().expect("public packages poisoned"); + let classified_at: Option = conn + .query_row( + "SELECT classified_at_ms FROM public_packages WHERE name = ?1", + rusqlite::params![name], + |row| row.get(0), + ) + .ok(); + let Some(classified_at) = classified_at else { + return false; + }; + match ttl { + None => true, + Some(ttl) => now_ms().saturating_sub(classified_at) <= ttl.as_millis() as i64, + } + } + + /// Record (or refresh) `name` as anonymously readable. Best-effort. + pub(crate) fn record(&self, name: &str) { + let now = now_ms(); + let conn = self.conn.lock().expect("public packages poisoned"); + let _ = conn.execute( + "INSERT INTO public_packages (name, classified_at_ms) VALUES (?1, ?2) + ON CONFLICT(name) DO UPDATE SET classified_at_ms = excluded.classified_at_ms", + rusqlite::params![name, now], + ); + evict_overflow(&conn); + } +} + +/// Trim the oldest rows past [`MAX_ROWS`], ordered by `classified_at_ms`. +fn evict_overflow(conn: &Connection) { + let _ = conn.execute( + "DELETE FROM public_packages WHERE name IN ( + SELECT name FROM public_packages + ORDER BY classified_at_ms DESC + LIMIT -1 OFFSET ?1 + )", + rusqlite::params![MAX_ROWS], + ); +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests; diff --git a/pnpr/crates/pnpr/src/install_accelerator/public_packages/tests.rs b/pnpr/crates/pnpr/src/install_accelerator/public_packages/tests.rs new file mode 100644 index 0000000000..ad3e2b1cbf --- /dev/null +++ b/pnpr/crates/pnpr/src/install_accelerator/public_packages/tests.rs @@ -0,0 +1,42 @@ +use std::{thread::sleep, time::Duration}; + +use tempfile::TempDir; + +use super::PublicPackages; + +fn open() -> (PublicPackages, TempDir) { + let dir = TempDir::new().expect("tempdir"); + let table = PublicPackages::open(&dir.path().join("public.sqlite")).expect("open"); + (table, dir) +} + +#[test] +fn records_and_reads_a_classification() { + let (table, _dir) = open(); + assert!(!table.is_public("lodash", None)); + table.record("lodash"); + assert!(table.is_public("lodash", None)); + // Classification is per name, not per other name. + assert!(!table.is_public("react", None)); +} + +#[test] +fn a_ttl_expires_an_old_classification() { + let (table, _dir) = open(); + table.record("lodash"); + assert!(table.is_public("lodash", Some(Duration::from_secs(60)))); + sleep(Duration::from_millis(5)); + assert!(!table.is_public("lodash", Some(Duration::from_millis(1)))); +} + +#[test] +fn classifications_persist_across_reopen() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("public.sqlite"); + { + let table = PublicPackages::open(&path).expect("open"); + table.record("lodash"); + } + let reopened = PublicPackages::open(&path).expect("reopen"); + assert!(reopened.is_public("lodash", None)); +} diff --git a/pnpr/crates/pnpr/src/install_accelerator/resolve.rs b/pnpr/crates/pnpr/src/install_accelerator/resolve.rs index 366e957f08..8c7eb09606 100644 --- a/pnpr/crates/pnpr/src/install_accelerator/resolve.rs +++ b/pnpr/crates/pnpr/src/install_accelerator/resolve.rs @@ -14,7 +14,7 @@ use std::{ use dashmap::DashMap; use pacquet_config::{Config, NodeLinker}; use pacquet_lockfile::{Lockfile, LockfileResolution}; -use pacquet_network::ThrottledClient; +use pacquet_network::{AuthHeaders, ThrottledClient}; use pacquet_package_manager::{Install, ResolvedPackages}; use pacquet_package_manifest::{DependencyGroup, PackageManifest}; use pacquet_reporter::SilentReporter; @@ -71,6 +71,7 @@ pub async fn resolve( config: &'static Config, client: &Arc, request: &InstallRequest, + auth_headers: &Arc, ) -> Result { let projects = request.projects_normalized(); @@ -191,6 +192,9 @@ pub async fn resolve( node_linker: NodeLinker::Isolated, lockfile_only: true, update_seed_policy: pacquet_package_manager::UpdateSeedPolicy::KeepAll, + // Resolve as the caller (forwarded credentials) without baking + // per-user auth into the interned `&'static Config`. + auth_override: Some(Arc::clone(auth_headers)), } .run::() .await @@ -224,11 +228,16 @@ pub fn collect_packages(lockfile: &Lockfile, registry: &str) -> Vec /// Fetch into the shared store every package whose store-index row is /// absent, populating its `PackageFilesIndex` as a side effect. Cached /// packages are skipped, matching the server hot-cache no-op. +/// +/// Returns the `pkg_id`s actually fetched this call — the upstream +/// accepted the caller's credentials for each, so the gate treats a +/// freshly-fetched private package as proven (no re-verify). pub async fn fetch_uncached( config: &'static Config, client: &Arc, + auth_headers: &AuthHeaders, packages: &[ResolvedPkg], -) -> Result<(), ResolveError> { +) -> Result, ResolveError> { let store_dir = &config.store_dir; let present: HashSet = match StoreIndex::open_readonly_in(store_dir) { @@ -243,9 +252,11 @@ pub async fn fetch_uncached( .collect(); if to_fetch.is_empty() { - return Ok(()); + return Ok(HashSet::new()); } + let fetched_ids: HashSet = to_fetch.iter().map(|pkg| pkg.pkg_id.clone()).collect(); + let integrities: Vec> = to_fetch.iter().map(|pkg| pkg.integrity.parse::().ok()).collect(); @@ -270,7 +281,7 @@ pub async fn fetch_uncached( package_unpacked_size: None, package_url: &pkg.tarball_url, package_id: &pkg.pkg_id, - auth_headers: &config.auth_headers, + auth_headers, requester: "pnpr", prefetched_cas_paths: None, retry_opts: RetryOpts::default(), @@ -291,7 +302,7 @@ pub async fn fetch_uncached( for result in results { result?; } - Ok(()) + Ok(fetched_ids) } /// Derive `(integrity, tarball_url)` for a resolution, mirroring diff --git a/pnpr/crates/pnpr/src/install_accelerator/tests.rs b/pnpr/crates/pnpr/src/install_accelerator/tests.rs index 08cca8bafc..64763d459c 100644 --- a/pnpr/crates/pnpr/src/install_accelerator/tests.rs +++ b/pnpr/crates/pnpr/src/install_accelerator/tests.rs @@ -1,19 +1,34 @@ -//! Tests for the per-caller access gate the install accelerator applies -//! before serving a package's files: a digest in the store is not a -//! bearer capability, so [`deny_unauthorized_packages`] checks every -//! served package against pnpr's own `packages:` policy. +//! Tests for the pnpr-as-authority access gate the install accelerator +//! applies before serving a package's files: a digest in the store is not +//! a bearer capability, so [`deny_local_policy`] checks every locally- +//! authoritative package against pnpr's own `packages:` policy. (The +//! upstream-as-authority regime — forwarded-credential content gated per +//! user — is exercised end to end in the pnpr-client integration tests.) + +use std::collections::HashSet; use axum::http::StatusCode; +use pacquet_network::AuthHeaders; +use tempfile::TempDir; -use super::{deny_unauthorized_packages, diff::PackageIndexEntry}; +use super::{ + InstallAccelerator, authorize_served_packages, authorize_upstream_package, deny_local_policy, + diff::PackageIndexEntry, protocol::InstallRequest, +}; use crate::policy::{AccessList, Identity, PackagePolicies, PackagePolicy}; -fn served(name: &str) -> Vec { - vec![PackageIndexEntry { - integrity: "sha512-deadbeef".to_string(), - pkg_id: format!("{name}@1.0.0"), - raw: Vec::new(), - }] +/// The `name@1.0.0` package id a served entry would carry. +fn served(name: &str) -> String { + format!("{name}@1.0.0") +} + +/// Run the local-policy gate over a single served package id. +fn deny( + policies: &PackagePolicies, + identity: &Identity, + pkg_id: &str, +) -> Option { + deny_local_policy(policies, identity, std::iter::once(pkg_id)) } fn anonymous() -> Identity { @@ -43,31 +58,297 @@ fn team_owned_by_alice() -> PackagePolicies { #[test] fn anonymous_caller_is_denied_a_private_package() { - let denied = deny_unauthorized_packages(&policies(), &anonymous(), &served("@private/foo")); + let denied = deny(&policies(), &anonymous(), &served("@private/foo")); assert_eq!(denied.map(|response| response.status()), Some(StatusCode::UNAUTHORIZED)); } #[test] fn authenticated_caller_is_allowed_a_private_package() { - let denied = deny_unauthorized_packages(&policies(), &user(), &served("@private/foo")); + let denied = deny(&policies(), &user(), &served("@private/foo")); assert!(denied.is_none()); } #[test] fn anonymous_caller_is_allowed_a_public_package() { - let denied = deny_unauthorized_packages(&policies(), &anonymous(), &served("is-positive")); + let denied = deny(&policies(), &anonymous(), &served("is-positive")); assert!(denied.is_none()); } #[test] fn authenticated_caller_outside_the_allowed_set_is_forbidden() { let bob = Identity::User { username: "bob".to_string() }; - let denied = deny_unauthorized_packages(&team_owned_by_alice(), &bob, &served("@team/foo")); + let denied = deny(&team_owned_by_alice(), &bob, &served("@team/foo")); assert_eq!(denied.map(|response| response.status()), Some(StatusCode::FORBIDDEN)); } #[test] fn authenticated_caller_in_the_allowed_set_is_allowed() { - let denied = deny_unauthorized_packages(&team_owned_by_alice(), &user(), &served("@team/foo")); + let denied = deny(&team_owned_by_alice(), &user(), &served("@team/foo")); assert!(denied.is_none()); } + +// -------------------------------------------------------------------- +// Upstream-as-authority regime: forwarded-credential content gated per +// user against the owning external registry, plus the grant table. +// -------------------------------------------------------------------- + +/// Build a real [`InstallAccelerator`] (store/cache dirs + grant table) +/// under `storage` for the dispatch tests. +fn accelerator(storage: &std::path::Path) -> InstallAccelerator { + let addr = "127.0.0.1:4873".parse().expect("addr parses"); + let config = crate::config::Config::proxy(addr, storage.to_path_buf()); + InstallAccelerator::build(&config) +} + +/// An [`AuthHeaders`] carrying a single default-registry credential for +/// `registry`, mirroring how a client forwards one upstream token. +fn auth_for(registry: &str, header: &str) -> AuthHeaders { + AuthHeaders::from_creds_map([(String::new(), header.to_string())], Some(registry)) +} + +fn entry(pkg_id: &str) -> PackageIndexEntry { + PackageIndexEntry { + integrity: "sha512-x".to_string(), + pkg_id: pkg_id.to_string(), + raw: Vec::new(), + } +} + +fn is_granted(acc: &InstallAccelerator, user: &str, pkg: &str) -> bool { + acc.grant_table.as_ref().expect("grant table opened").is_granted(user, pkg, None) +} + +fn is_public(acc: &InstallAccelerator, name: &str) -> bool { + acc.public_packages.as_ref().expect("public set opened").is_public(name, None) +} + +fn fresh(pkg_ids: &[&str]) -> HashSet { + pkg_ids.iter().map(|id| id.to_string()).collect() +} + +#[tokio::test] +async fn a_fresh_upstream_fetch_is_allowed_and_records_a_grant() { + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + let auth = auth_for("https://reg.test/", "Bearer t"); + let identity = Identity::User { username: "alice".to_string() }; + + let denied = authorize_upstream_package( + &acc, + &identity, + &auth, + &fresh(&["foo@1.0.0"]), + "https://reg.test/", + "foo", + "foo@1.0.0", + ) + .await; + + assert!(denied.is_none()); + assert!(is_granted(&acc, "alice", "foo@1.0.0")); +} + +#[tokio::test] +async fn a_granted_cache_hit_is_served_without_touching_the_upstream() { + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + acc.grant_table.as_ref().unwrap().record("alice", "foo@1.0.0"); + let auth = auth_for("https://reg.test/", "Bearer t"); + let identity = Identity::User { username: "alice".to_string() }; + + // An unreachable registry: a network probe would resolve to a 502 + // denial, so a pass here proves the grant short-circuited it. + let denied = authorize_upstream_package( + &acc, + &identity, + &auth, + &fresh(&[]), + "http://127.0.0.1:1/", + "foo", + "foo@1.0.0", + ) + .await; + + assert!(denied.is_none()); +} + +#[tokio::test] +async fn an_ungranted_private_cache_hit_reverifies_then_records() { + let mut server = mockito::Server::new_async().await; + // Private: the registry withholds the packument anonymously, then + // serves it once the caller's credential is attached. The two mocks + // are mutually exclusive on the `authorization` header. + let anon = server + .mock("GET", "/foo") + .match_header("authorization", mockito::Matcher::Missing) + .with_status(401) + .create_async() + .await; + let authed = server + .mock("GET", "/foo") + .match_header("authorization", "Bearer t") + .with_status(200) + .with_body("{}") + .create_async() + .await; + let registry = format!("{}/", server.url()); + + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + let auth = auth_for(®istry, "Bearer t"); + let identity = Identity::User { username: "alice".to_string() }; + + let denied = authorize_upstream_package( + &acc, + &identity, + &auth, + &fresh(&[]), + ®istry, + "foo", + "foo@1.0.0", + ) + .await; + + assert!(denied.is_none()); + anon.assert_async().await; + authed.assert_async().await; + assert!(is_granted(&acc, "alice", "foo@1.0.0")); + // A private package must never be cached as public. + assert!(!is_public(&acc, "foo")); +} + +#[tokio::test] +async fn a_public_cache_hit_is_classified_once_then_served_for_free() { + let mut server = mockito::Server::new_async().await; + // Public: the registry serves the packument anonymously. Exactly one + // probe is expected across both authorize calls — the second is served + // from the global classification with no upstream contact. + let mock = + server.mock("GET", "/foo").with_status(200).with_body("{}").expect(1).create_async().await; + let registry = format!("{}/", server.url()); + + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + let auth = auth_for(®istry, "Bearer t"); + let alice = Identity::User { username: "alice".to_string() }; + + let first = + authorize_upstream_package(&acc, &alice, &auth, &fresh(&[]), ®istry, "foo", "foo@1.0.0") + .await; + assert!(first.is_none()); + assert!(is_public(&acc, "foo")); + // Public content records no per-user grant. + assert!(!is_granted(&acc, "alice", "foo@1.0.0")); + + // A different caller wanting a different cached version is served + // straight from the classification — no second probe. + let bob = Identity::User { username: "bob".to_string() }; + let second = + authorize_upstream_package(&acc, &bob, &auth, &fresh(&[]), ®istry, "foo", "foo@2.0.0") + .await; + assert!(second.is_none()); + + mock.assert_async().await; +} + +#[tokio::test] +async fn a_denied_reverify_clears_the_users_grants_and_denies() { + let mut server = mockito::Server::new_async().await; + let _mock = server.mock("GET", "/foo").with_status(403).create_async().await; + let registry = format!("{}/", server.url()); + + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + // A standing grant for another version the caller already held: a + // discovered `403` for the package must purge it (clear-on-discovery). + acc.grant_table.as_ref().unwrap().record("alice", "foo@2.0.0"); + let auth = auth_for(®istry, "Bearer t"); + let identity = Identity::User { username: "alice".to_string() }; + + let denied = authorize_upstream_package( + &acc, + &identity, + &auth, + &fresh(&[]), + ®istry, + "foo", + "foo@1.0.0", + ) + .await; + + assert_eq!(denied.map(|response| response.status()), Some(StatusCode::FORBIDDEN)); + assert!(!is_granted(&acc, "alice", "foo@2.0.0")); +} + +#[tokio::test] +async fn an_unreachable_upstream_during_reverify_is_a_bad_gateway() { + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + let auth = auth_for("http://127.0.0.1:1/", "Bearer t"); + let identity = Identity::User { username: "alice".to_string() }; + + // Port 1 refuses the connection, so neither the anonymous classify + // probe nor the authed re-verify can decide access. + let denied = authorize_upstream_package( + &acc, + &identity, + &auth, + &fresh(&[]), + "http://127.0.0.1:1/", + "foo", + "foo@1.0.0", + ) + .await; + + assert_eq!(denied.map(|response| response.status()), Some(StatusCode::BAD_GATEWAY)); +} + +#[tokio::test] +async fn a_forwarded_credential_routes_around_the_local_policy() { + // `@private/foo` is gated to `$authenticated` by the local policy, so + // an anonymous caller would be denied under pnpr-as-authority. With a + // forwarded credential it is upstream-as-authority instead, and a + // fresh fetch proves access — so it is served. + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + let registry = "https://reg.test/"; + let auth = auth_for(registry, "Bearer t"); + let request = InstallRequest { registry: Some(registry.to_string()), ..Default::default() }; + + let denied = authorize_served_packages( + &acc, + &policies(), + &Identity::Anonymous, + &request, + &auth, + &fresh(&["@private/foo@1.0.0"]), + &[entry("@private/foo@1.0.0")], + ) + .await; + + assert!(denied.is_none()); +} + +#[tokio::test] +async fn without_a_forwarded_credential_the_local_policy_still_applies() { + let tmp = TempDir::new().unwrap(); + let acc = accelerator(tmp.path()); + let request = + InstallRequest { registry: Some("https://reg.test/".to_string()), ..Default::default() }; + + // No forwarded credential ⇒ pnpr-as-authority ⇒ `@private/foo` is + // denied to an anonymous caller, exactly as the packument/tarball + // endpoints would deny it. + let denied = authorize_served_packages( + &acc, + &policies(), + &Identity::Anonymous, + &request, + &AuthHeaders::default(), + &fresh(&[]), + &[entry("@private/foo@1.0.0")], + ) + .await; + + assert_eq!(denied.map(|response| response.status()), Some(StatusCode::UNAUTHORIZED)); +}