From 0a4513ebd709eb2263c236c425d4000ee0a32e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 12 Jul 2026 12:35:37 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ui):=20enforce=20OCI=20read?= =?UTF-8?q?=20ACLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous users could browse private OCI repositories through neutral UI and search routes because those routes skipped index read ACLs. Check OCI read ACLs before browse drivers read repository data. Add readable resource globs to the Tantivy query so private repositories do not affect totals or pages. Resolve Basic credentials once per index and retain the public fast path. Fixes #190 --- Cargo.lock | 1 + crates/peryx-driver/src/access.rs | 160 ++++++++++++++ crates/peryx-driver/src/lib.rs | 1 + crates/peryx-driver/src/tests/access_tests.rs | 93 ++++++++ crates/peryx-driver/src/tests/mod.rs | 1 + crates/peryx-http/Cargo.toml | 2 +- crates/peryx-http/src/handlers/dispatch.rs | 2 +- crates/peryx-http/src/handlers/query.rs | 56 +++-- crates/peryx-http/src/handlers/ui.rs | 81 ++++++- crates/peryx-identity/src/acl.rs | 47 ++++ crates/peryx-identity/src/tests/acl_tests.rs | 23 ++ crates/peryx-search/src/access.rs | 19 ++ crates/peryx-search/src/engine.rs | 76 ++++++- crates/peryx-search/src/lib.rs | 2 + .../src/tests/integration_tests.rs | 76 ++++++- crates/peryx-web/Cargo.toml | 2 + crates/peryx-web/src/data/search.rs | 2 +- crates/peryx-web/src/data/simple.rs | 2 +- crates/peryx-web/src/ssr/mod.rs | 11 + crates/peryx-web/src/ssr/search.rs | 17 +- crates/peryx-web/src/ssr/simple.rs | 27 ++- crates/peryx/src/tests/ui_tests.rs | 200 +++++++++++++++++- site/content/contributing/architecture.md | 8 + .../ecosystems/oci/guides/token-auth.md | 4 + .../ecosystems/oci/reference/token-auth.md | 16 ++ site/content/ecosystems/oci/token-realm.md | 5 + .../ecosystems/oci/tutorials/scoped-token.md | 4 + 27 files changed, 896 insertions(+), 42 deletions(-) create mode 100644 crates/peryx-driver/src/access.rs create mode 100644 crates/peryx-driver/src/tests/access_tests.rs create mode 100644 crates/peryx-search/src/access.rs diff --git a/Cargo.lock b/Cargo.lock index ab04e999..db401132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3704,6 +3704,7 @@ dependencies = [ "peryx-core", "peryx-driver", "peryx-http", + "peryx-identity", "peryx-search", "peryx-storage", "pulldown-cmark", diff --git a/crates/peryx-driver/src/access.rs b/crates/peryx-driver/src/access.rs new file mode 100644 index 00000000..220f4a07 --- /dev/null +++ b/crates/peryx-driver/src/access.rs @@ -0,0 +1,160 @@ +//! Neutral HTTP surfaces share this resolver so presentation routes enforce index ACLs. + +use std::borrow::Cow; + +use axum::http::{HeaderMap, header}; +use peryx_identity::{Action, Denial, Grant, Principal, authorize, authorize_grants}; +use peryx_search::{SearchAccess, SearchAccessPattern}; + +use crate::{Index, ServingState}; + +pub struct ReadAccess { + credential: Credential, +} + +enum Credential { + Acl { header: Option, now: i64 }, + Bearer(Vec), +} + +pub struct IndexReadAccess<'a> { + index: &'a Index, + credential: IndexCredential<'a>, +} + +enum IndexCredential<'a> { + Public, + Acl(Principal), + Bearer(&'a [Grant]), +} + +impl ReadAccess { + #[must_use] + pub fn from_headers(state: &ServingState, headers: &HeaderMap) -> Self { + let header = headers.get(header::AUTHORIZATION).and_then(|value| value.to_str().ok()); + let credential = if let Some(token) = header.and_then(|value| value.strip_prefix("Bearer ")) + && let Some(signer) = &state.signer + && let Ok((_, grants)) = signer.verify(token) + { + Credential::Bearer(grants) + } else { + Credential::Acl { + header: header.map(str::to_owned), + now: (state.clock)(), + } + }; + Self { credential } + } + + #[must_use] + pub fn for_index<'a>(&'a self, index: &'a Index) -> IndexReadAccess<'a> { + let credential = if index.acl.anonymous_read { + IndexCredential::Public + } else { + match &self.credential { + Credential::Acl { header, now } => { + IndexCredential::Acl(index.acl.identify(header.as_deref(), *now).principal) + } + Credential::Bearer(grants) => IndexCredential::Bearer(grants), + } + }; + IndexReadAccess { index, credential } + } + + #[must_use] + pub fn search_access(&self, indexes: &[Index]) -> SearchAccess { + let mut patterns = Vec::new(); + for index in indexes { + let access = self.for_index(index); + match &access.credential { + IndexCredential::Public => patterns.push(SearchAccessPattern { + route: index.route.clone(), + glob: "*".to_owned(), + }), + IndexCredential::Acl(principal) => { + patterns.extend(read_globs(index, principal).map(|glob| SearchAccessPattern { + route: index.route.clone(), + glob: glob.to_owned(), + })); + } + IndexCredential::Bearer(grants) => { + let prefix = resource_prefix(&index.route); + for glob in grants + .iter() + .filter(|grant| grant.actions.contains(&Action::Read)) + .flat_map(|grant| &grant.projects) + { + patterns.extend(glob.remainders_after(&prefix).map(|glob| SearchAccessPattern { + route: index.route.clone(), + glob: glob.to_owned(), + })); + } + } + } + } + SearchAccess::new(patterns) + } +} + +impl IndexReadAccess<'_> { + /// Avoids index enumeration when the credential holds no possible read. + /// + /// # Errors + /// Returns the index ACL denial when no read grant can cover a project. + pub fn authorize_any_project(&self) -> Result<(), Denial> { + match &self.credential { + IndexCredential::Public => Ok(()), + IndexCredential::Acl(principal) => authorize(principal, &self.index.acl, None, Action::Read), + IndexCredential::Bearer(grants) => { + let prefix = resource_prefix(&self.index.route); + grants + .iter() + .any(|grant| { + grant.actions.contains(&Action::Read) + && grant.projects.iter().any(|project| project.matches_prefix(&prefix)) + }) + .then_some(()) + .ok_or(Denial::Forbidden) + } + } + } + + /// Prevents a repository grant from exposing its siblings. + /// + /// # Errors + /// Returns the index ACL denial when the credential cannot read `project`. + pub fn authorize_project(&self, project: &str) -> Result<(), Denial> { + match &self.credential { + IndexCredential::Public => Ok(()), + IndexCredential::Acl(principal) => authorize(principal, &self.index.acl, Some(project), Action::Read), + IndexCredential::Bearer(grants) => { + authorize_grants(grants, Some(&resource_name(&self.index.route, project)), Action::Read) + } + } + } +} + +fn read_globs<'a>(index: &'a Index, principal: &'a Principal) -> impl Iterator { + index + .acl + .grants(principal) + .iter() + .filter(|grant| grant.actions.contains(&Action::Read)) + .flat_map(|grant| grant.projects.iter().map(peryx_identity::Glob::as_str)) +} + +fn resource_prefix(route: &str) -> Cow<'_, str> { + if route.is_empty() { + Cow::Borrowed(route) + } else { + Cow::Owned(format!("{route}/")) + } +} + +fn resource_name<'a>(route: &str, project: &'a str) -> Cow<'a, str> { + if route.is_empty() { + Cow::Borrowed(project) + } else { + Cow::Owned(format!("{route}/{project}")) + } +} diff --git a/crates/peryx-driver/src/lib.rs b/crates/peryx-driver/src/lib.rs index a3182314..2a302f3a 100644 --- a/crates/peryx-driver/src/lib.rs +++ b/crates/peryx-driver/src/lib.rs @@ -9,6 +9,7 @@ //! The router that dispatches to a driver sits *above* this crate, in `peryx-http`. An ecosystem //! therefore never depends on the serving layer that hosts it, only on the seam it fills. +pub mod access; pub mod body; pub mod discovery; pub mod download; diff --git a/crates/peryx-driver/src/tests/access_tests.rs b/crates/peryx-driver/src/tests/access_tests.rs new file mode 100644 index 00000000..9b75343d --- /dev/null +++ b/crates/peryx-driver/src/tests/access_tests.rs @@ -0,0 +1,93 @@ +use std::collections::BTreeSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::http::{HeaderMap, HeaderValue, header}; +use peryx_core::Ecosystem; +use peryx_identity::{Action, Glob, Grant, IndexAcl, Principal, Signer}; +use rstest::rstest; + +use crate::access::ReadAccess; +use crate::{AppState, Index, IndexKind}; + +#[rstest] +#[case::root("", "app")] +#[case::nested("images", "images/app")] +fn test_bearer_read_access_joins_index_routes(#[case] route: &str, #[case] resource: &str) { + let (_dir, state, headers) = app(route, resource); + let access = ReadAccess::from_headers(&state, &headers); + + assert_eq!(access.for_index(state.index_at(0)).authorize_project("app"), Ok(())); +} + +#[rstest] +#[case::root("", "app")] +#[case::nested("images", "images/app")] +fn test_bearer_read_access_finds_projects_under_index_routes(#[case] route: &str, #[case] resource: &str) { + let (_dir, state, headers) = app(route, resource); + let access = ReadAccess::from_headers(&state, &headers); + + assert_eq!(access.for_index(state.index_at(0)).authorize_any_project(), Ok(())); +} + +#[rstest] +#[case::any(None)] +#[case::named(Some("app"))] +fn test_public_read_access_allows_projects(#[case] project: Option<&str>) { + let (_dir, mut state, _) = app("", "app"); + state.indexes[0].acl.anonymous_read = true; + let access = ReadAccess::from_headers(&state, &HeaderMap::new()); + let access = access.for_index(state.index_at(0)); + + assert_eq!( + project.map_or_else( + || access.authorize_any_project(), + |project| access.authorize_project(project), + ), + Ok(()) + ); +} + +fn app(route: &str, resource: &str) -> (tempfile::TempDir, AppState, HeaderMap) { + let dir = tempfile::tempdir().unwrap(); + let meta = peryx_storage::meta::MetaStore::open(dir.path().join("peryx.redb")).unwrap(); + let blobs = peryx_storage::blob::BlobStore::new(dir.path().join("blobs")); + let mut state = AppState::new( + meta, + blobs, + 60, + vec![Index { + name: "images".to_owned(), + route: route.to_owned(), + ecosystem: Ecosystem::Oci, + kind: IndexKind::Hosted { volatile: true }, + policy: peryx_policy::Policy::default(), + acl: IndexAcl { + anonymous_read: false, + tokens: Vec::new(), + }, + }], + ); + let signer = Signer::new(b"signing-secret", "peryx"); + let token = signer.mint( + &Principal::Named { + subject: "reader".to_owned(), + }, + &[Grant { + projects: vec![Glob::new(resource)], + actions: BTreeSet::from([Action::Read]), + }], + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .cast_signed(), + 300, + ); + state.set_token_realm(signer, 300); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + (dir, state, headers) +} diff --git a/crates/peryx-driver/src/tests/mod.rs b/crates/peryx-driver/src/tests/mod.rs index 6988a7e1..607aa55d 100644 --- a/crates/peryx-driver/src/tests/mod.rs +++ b/crates/peryx-driver/src/tests/mod.rs @@ -1,2 +1,3 @@ +mod access_tests; mod body_tests; mod state_tests; diff --git a/crates/peryx-http/Cargo.toml b/crates/peryx-http/Cargo.toml index cf0d20aa..a7b7fc25 100644 --- a/crates/peryx-http/Cargo.toml +++ b/crates/peryx-http/Cargo.toml @@ -12,6 +12,7 @@ peryx-core.workspace = true peryx-driver.workspace = true peryx-events.workspace = true peryx-index.workspace = true +peryx-identity.workspace = true peryx-search.workspace = true peryx-storage.workspace = true axum = { workspace = true, features = ["multipart"] } @@ -22,7 +23,6 @@ tracing.workspace = true tokio = { workspace = true, features = ["sync", "rt", "fs"] } [dev-dependencies] -peryx-identity.workspace = true async-trait.workspace = true peryx-policy.workspace = true rstest.workspace = true diff --git a/crates/peryx-http/src/handlers/dispatch.rs b/crates/peryx-http/src/handlers/dispatch.rs index a22261c5..f25ec0cc 100644 --- a/crates/peryx-http/src/handlers/dispatch.rs +++ b/crates/peryx-http/src/handlers/dispatch.rs @@ -67,7 +67,7 @@ pub async fn dispatch_get( }; match rest { "+api" | "+api/" => index_api(&state, position, &uri, &headers), - "+search" | "+search/" => index_search(state, position, &uri).await, + "+search" | "+search/" => index_search(state, position, &uri, &headers).await, _ => { let serving = match driver_at(&state, position) { Ok(serving) => serving.clone(), diff --git a/crates/peryx-http/src/handlers/query.rs b/crates/peryx-http/src/handlers/query.rs index f942cbf1..4b977486 100644 --- a/crates/peryx-http/src/handlers/query.rs +++ b/crates/peryx-http/src/handlers/query.rs @@ -3,37 +3,45 @@ use std::sync::Arc; use axum::extract::{OriginalUri, State}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; -use peryx_driver::state::AppState; -use peryx_search::{SearchError, SearchParams}; +use peryx_driver::access::ReadAccess; +use peryx_driver::state::{AppState, Index}; +use peryx_search::{SearchAccess, SearchError, SearchParams}; /// `GET /{route}/+search`: search cached packages scoped to one index. -pub(super) async fn index_search(state: Arc, position: usize, uri: &axum::http::Uri) -> Response { +pub(super) async fn index_search( + state: Arc, + position: usize, + uri: &axum::http::Uri, + headers: &HeaderMap, +) -> Response { let mut params = match SearchParams::from_query(uri.query()) { Ok(params) => params, Err(err) => return search_error_response(&err), }; params.route = Some(state.index_at(position).route.clone()); - search_response_offloaded(state, params).await + let access = search_access(&state, headers, std::slice::from_ref(state.index_at(position))); + search_response_offloaded(state, params, access).await } /// `GET /+search`: search cached packages across configured indexes. -pub async fn search(State(state): State>, OriginalUri(uri): OriginalUri) -> Response { +pub async fn search(State(state): State>, OriginalUri(uri): OriginalUri, headers: HeaderMap) -> Response { match SearchParams::from_query(uri.query()) { - Ok(params) => search_response_offloaded(state, params).await, + Ok(params) => { + let access = search_access(&state, &headers, &state.indexes); + search_response_offloaded(state, params, access).await + } Err(err) => search_error_response(&err), } } -/// Run a search over cached package documents and render the result document. -#[must_use] -pub fn search_response(state: &AppState, params: SearchParams) -> Response { - match state.search.search(&state.search_ctx(), params) { - Ok(results) => axum::Json(results).into_response(), - Err(err) => search_error_response(&err), +fn search_access(state: &AppState, headers: &HeaderMap, indexes: &[Index]) -> Option { + if indexes.iter().all(|index| index.acl.anonymous_read) { + return None; } + Some(ReadAccess::from_headers(state, headers).search_access(indexes)) } /// Run [`search_response`] on the blocking pool. A tantivy query is mmap I/O plus CPU scoring, so @@ -42,12 +50,30 @@ pub fn search_response(state: &AppState, params: SearchParams) -> Response { /// # Panics /// Panics if the blocking task panics; [`search_response`] returns every error as a response, so it /// does not. -pub async fn search_response_offloaded(state: Arc, params: SearchParams) -> Response { - tokio::task::spawn_blocking(move || search_response(&state, params)) +pub async fn search_response_offloaded( + state: Arc, + params: SearchParams, + access: Option, +) -> Response { + tokio::task::spawn_blocking(move || search_response(&state, params, access.as_ref())) .await .expect("search task never panics") } +/// Run a search over cached package documents and render the result document. +#[must_use] +pub fn search_response(state: &AppState, params: SearchParams, access: Option<&SearchAccess>) -> Response { + let result = if let Some(access) = access { + state.search.search_authorized(&state.search_ctx(), params, access) + } else { + state.search.search(&state.search_ctx(), params) + }; + match result { + Ok(results) => axum::Json(results).into_response(), + Err(err) => search_error_response(&err), + } +} + /// Map a [`SearchError`] to a JSON error response. #[must_use] pub fn search_error_response(err: &SearchError) -> Response { diff --git a/crates/peryx-http/src/handlers/ui.rs b/crates/peryx-http/src/handlers/ui.rs index 0372337f..c2fd7bdb 100644 --- a/crates/peryx-http/src/handlers/ui.rs +++ b/crates/peryx-http/src/handlers/ui.rs @@ -7,11 +7,13 @@ use std::sync::Arc; use axum::extract::{Query, State}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; +use peryx_driver::access::ReadAccess; use peryx_driver::serving::EcosystemDriver; use peryx_driver::state::AppState; +use peryx_identity::Denial; #[derive(Debug, serde::Deserialize)] pub struct IndexQuery { @@ -50,21 +52,45 @@ pub struct MemberQuery { } /// `GET /+ui/projects?index=`: the project names of one index. -pub async fn ui_projects(State(state): State>, Query(query): Query) -> Response { +pub async fn ui_projects( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Response { let Some((position, driver)) = resolve(&state, &query.index) else { return StatusCode::NOT_FOUND.into_response(); }; + let index = state.index_at(position); + let read_access = (!index.acl.anonymous_read).then(|| ReadAccess::from_headers(&state, &headers)); + let access = read_access.as_ref().map(|access| access.for_index(index)); + if let Some(access) = &access + && let Err(denial) = access.authorize_any_project() + { + return denied(denial); + } match driver.project_names(&state.serving, position) { - Ok(names) => axum::Json(names).into_response(), + Ok(mut names) => { + if let Some(access) = access { + names.retain(|project| access.authorize_project(project).is_ok()); + } + axum::Json(names).into_response() + } Err(message) => server_error(&message), } } /// `GET /+ui/project?index=&project=`: one project's browse view, `404` when absent. -pub async fn ui_project(State(state): State>, Query(query): Query) -> Response { +pub async fn ui_project( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Response { let Some((position, driver)) = resolve(&state, &query.index) else { return StatusCode::NOT_FOUND.into_response(); }; + if let Err(denial) = authorize_project(&state, &headers, position, &query.project) { + return denied(denial); + } match driver .browse_project(state.serving.clone(), position, query.project) .await @@ -77,10 +103,17 @@ pub async fn ui_project(State(state): State>, Query(query): Query< /// `GET /+ui/manifest?index=&project=&ref=`: a manifest view, `404` when /// the reference is not served. -pub async fn ui_manifest(State(state): State>, Query(query): Query) -> Response { +pub async fn ui_manifest( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Response { let Some((position, driver)) = resolve(&state, &query.index) else { return StatusCode::NOT_FOUND.into_response(); }; + if let Err(denial) = authorize_project(&state, &headers, position, &query.project) { + return denied(denial); + } match driver .manifest_view(state.serving.clone(), position, query.project, query.reference) .await @@ -92,10 +125,17 @@ pub async fn ui_manifest(State(state): State>, Query(query): Query } /// `GET /+ui/members?index=&project=&digest=`: a nested content item's members. -pub async fn ui_members(State(state): State>, Query(query): Query) -> Response { +pub async fn ui_members( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Response { let Some((position, driver)) = resolve(&state, &query.index) else { return StatusCode::NOT_FOUND.into_response(); }; + if let Err(denial) = authorize_project(&state, &headers, position, &query.project) { + return denied(denial); + } match driver .artifact_members(state.serving.clone(), position, query.project, query.digest) .await @@ -107,10 +147,17 @@ pub async fn ui_members(State(state): State>, Query(query): Query< /// `GET /+ui/member?index=&project=&digest=&member=&offset=`: one text /// chunk of a nested content member. -pub async fn ui_member(State(state): State>, Query(query): Query) -> Response { +pub async fn ui_member( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Response { let Some((position, driver)) = resolve(&state, &query.index) else { return StatusCode::NOT_FOUND.into_response(); }; + if let Err(denial) = authorize_project(&state, &headers, position, &query.project) { + return denied(denial); + } match driver .artifact_member_chunk( state.serving.clone(), @@ -137,3 +184,23 @@ fn resolve(state: &AppState, route: &str) -> Option<(usize, Arc Response { (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() } + +fn authorize_project(state: &AppState, headers: &HeaderMap, position: usize, project: &str) -> Result<(), Denial> { + if state.index_at(position).acl.anonymous_read { + return Ok(()); + } + ReadAccess::from_headers(state, headers) + .for_index(state.index_at(position)) + .authorize_project(project) +} + +fn denied(denial: Denial) -> Response { + if denial == Denial::Forbidden { + return StatusCode::FORBIDDEN.into_response(); + } + ( + StatusCode::UNAUTHORIZED, + [(axum::http::header::WWW_AUTHENTICATE, "Basic realm=\"peryx\"")], + ) + .into_response() +} diff --git a/crates/peryx-identity/src/acl.rs b/crates/peryx-identity/src/acl.rs index 4771d09b..63d489e9 100644 --- a/crates/peryx-identity/src/acl.rs +++ b/crates/peryx-identity/src/acl.rs @@ -261,6 +261,45 @@ impl Glob { Self(pattern.into()) } + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Whether some value beginning with `prefix` can match this pattern. + #[must_use] + pub fn matches_prefix(&self, prefix: &str) -> bool { + self.remainders_after(prefix).next().is_some() + } + + /// Pattern suffixes that can still match after `prefix` has been consumed. + pub fn remainders_after<'a>(&'a self, prefix: &str) -> impl Iterator { + let pattern = self.0.as_bytes(); + let mut reachable = vec![false; pattern.len() + 1]; + let mut next = vec![false; pattern.len() + 1]; + reachable[0] = true; + close_stars(pattern, &mut reachable); + for byte in prefix.bytes() { + next.fill(false); + for (position, &active) in reachable[..pattern.len()].iter().enumerate() { + if active { + if pattern[position] == b'*' { + next[position] = true; + } else if pattern[position] == byte { + next[position + 1] = true; + } + } + } + close_stars(pattern, &mut next); + std::mem::swap(&mut reachable, &mut next); + } + reachable + .into_iter() + .enumerate() + .filter(|(_, active)| *active) + .map(|(position, _)| &self.0[position..]) + } + /// Whether `project` matches this pattern, by the usual backtracking wildcard walk: on a mismatch, /// return to the last `*` and let it swallow one more character. #[must_use] @@ -287,3 +326,11 @@ impl Glob { pattern[at..].iter().all(|byte| *byte == b'*') } } + +fn close_stars(pattern: &[u8], reachable: &mut [bool]) { + for position in 0..pattern.len() { + if reachable[position] && pattern[position] == b'*' { + reachable[position + 1] = true; + } + } +} diff --git a/crates/peryx-identity/src/tests/acl_tests.rs b/crates/peryx-identity/src/tests/acl_tests.rs index 27c27653..3c27e3d1 100644 --- a/crates/peryx-identity/src/tests/acl_tests.rs +++ b/crates/peryx-identity/src/tests/acl_tests.rs @@ -54,6 +54,29 @@ fn test_glob_matches(#[case] pattern: &str, #[case] project: &str, #[case] expec assert_eq!(Glob::new(pattern).matches(project), expected); } +#[rstest] +#[case::literal_extension("images/app", "images/", true)] +#[case::literal_exhausted("images", "images/", false)] +#[case::wildcard_extension("images/team/*", "images/", true)] +#[case::wildcard_consumes_prefix("*/app", "images/", true)] +#[case::different_prefix("other/*", "images/", false)] +fn test_glob_matches_prefix(#[case] pattern: &str, #[case] prefix: &str, #[case] expected: bool) { + assert_eq!(Glob::new(pattern).matches_prefix(prefix), expected); +} + +#[rstest] +#[case::literal("images/app", "images/", &["app"])] +#[case::wildcard("images/team/*", "images/", &["team/*"])] +#[case::wildcard_prefix("*/app", "images/", &["*/app", "/app", "app"])] +#[case::root("app", "", &["app"])] +#[case::miss("other/*", "images/", &[])] +fn test_glob_remainders_after(#[case] pattern: &str, #[case] prefix: &str, #[case] expected: &[&str]) { + assert_eq!( + Glob::new(pattern).remainders_after(prefix).collect::>(), + expected + ); +} + #[test] fn test_authorize_grants_a_named_token_its_projects() { let acl = acl(vec![token("ci", "s3cret", grant(&["team/*"], &[Action::Write]))]); diff --git a/crates/peryx-search/src/access.rs b/crates/peryx-search/src/access.rs new file mode 100644 index 00000000..60075626 --- /dev/null +++ b/crates/peryx-search/src/access.rs @@ -0,0 +1,19 @@ +//! Search carries ACL predicates into the query so totals and pagination cannot leak private resources. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchAccess { + pub(crate) patterns: Vec, +} + +impl SearchAccess { + #[must_use] + pub const fn new(patterns: Vec) -> Self { + Self { patterns } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SearchAccessPattern { + pub route: String, + pub glob: String, +} diff --git a/crates/peryx-search/src/engine.rs b/crates/peryx-search/src/engine.rs index acefa42e..d281a292 100644 --- a/crates/peryx-search/src/engine.rs +++ b/crates/peryx-search/src/engine.rs @@ -6,12 +6,13 @@ use std::sync::{Arc, Mutex}; use tantivy::collector::{Count, TopDocs}; use tantivy::directory::MmapDirectory; -use tantivy::query::{AllQuery, BooleanQuery, Query, RegexQuery, TermQuery}; +use tantivy::query::{AllQuery, BooleanQuery, EmptyQuery, Query, RegexQuery, TermQuery}; use tantivy::schema::document::{TantivyDocument, Value as _}; use tantivy::schema::{FAST, Field, IndexRecordOption, STORED, STRING, Schema, TextFieldIndexing, TextOptions}; use tantivy::tokenizer::{LowerCaser, NgramTokenizer, TextAnalyzer, TokenizerManager}; use tantivy::{Index as TantivyIndex, IndexReader, Order, Term}; +use crate::access::{SearchAccess, SearchAccessPattern}; use crate::context::SearchCtx; use crate::error::SearchError; use crate::indexer::{CompositeIndexer, PackageDocument, PackageIndexer, default_indexer}; @@ -104,8 +105,30 @@ impl PackageSearch { /// # Errors /// Returns an error if the derived index cannot refresh or the query is invalid. pub fn search(&self, ctx: &SearchCtx<'_>, params: SearchParams) -> Result { + self.search_with_access(ctx, params, None) + } + + /// Apply access inside the query so totals and pages contain only readable resources. + /// + /// # Errors + /// Returns an error if the derived index cannot refresh or the query is invalid. + pub fn search_authorized( + &self, + ctx: &SearchCtx<'_>, + params: SearchParams, + access: &SearchAccess, + ) -> Result { + self.search_with_access(ctx, params, Some(access)) + } + + fn search_with_access( + &self, + ctx: &SearchCtx<'_>, + params: SearchParams, + access: Option<&SearchAccess>, + ) -> Result { self.ensure_current(ctx)?; - let query = self.query(¶ms)?; + let query = self.query(¶ms, access)?; let searcher = self.reader.searcher(); let offset = params.offset(); let top_docs = TopDocs::with_limit(params.page_size) @@ -164,7 +187,7 @@ impl PackageSearch { Ok(()) } - fn query(&self, params: &SearchParams) -> Result, SearchError> { + fn query(&self, params: &SearchParams, access: Option<&SearchAccess>) -> Result, SearchError> { let mut queries = vec![self.text_query(params.query.trim())?]; if let Some(source) = params.source.package_source() { queries.push(Box::new(TermQuery::new( @@ -178,6 +201,9 @@ impl PackageSearch { IndexRecordOption::Basic, ))); } + if let Some(access) = access { + queries.push(self.access_query(access)?); + } Ok(if queries.len() == 1 { queries.pop().expect("query exists") } else { @@ -185,6 +211,29 @@ impl PackageSearch { }) } + fn access_query(&self, access: &SearchAccess) -> Result, SearchError> { + let mut queries = access + .patterns + .iter() + .collect::>() + .into_iter() + .map(|SearchAccessPattern { route, glob }| { + let route_query = Box::new(TermQuery::new( + Term::from_field_text(self.fields.route, route), + IndexRecordOption::Basic, + )) as Box; + RegexQuery::from_pattern(&glob_regex(glob), self.fields.normalized).map(|project_query| { + Box::new(BooleanQuery::intersection(vec![route_query, Box::new(project_query)])) as Box + }) + }) + .collect::>>>()?; + Ok(match queries.len() { + 0 => Box::new(EmptyQuery), + 1 => queries.pop().expect("query exists"), + _ => Box::new(BooleanQuery::union(queries)), + }) + } + fn text_query(&self, query: &str) -> Result, SearchError> { if query.is_empty() { return Ok(Box::new(AllQuery)); @@ -364,11 +413,26 @@ pub fn truncate_to_chars(value: &str, max_bytes: usize) -> &str { fn escape_regex(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); + push_escaped_regex(&mut escaped, value); + escaped +} + +fn glob_regex(value: &str) -> String { + let mut pattern = String::with_capacity(value.len()); + let mut parts = value.split('*'); + push_escaped_regex(&mut pattern, parts.next().unwrap_or_default()); + for part in parts { + pattern.push_str(".*"); + push_escaped_regex(&mut pattern, part); + } + pattern +} + +fn push_escaped_regex(pattern: &mut String, value: &str) { for char in value.chars() { if REGEX_SPECIALS.contains(char) { - escaped.push('\\'); + pattern.push('\\'); } - escaped.push(char); + pattern.push(char); } - escaped } diff --git a/crates/peryx-search/src/lib.rs b/crates/peryx-search/src/lib.rs index 9b33914d..8380572f 100644 --- a/crates/peryx-search/src/lib.rs +++ b/crates/peryx-search/src/lib.rs @@ -4,6 +4,7 @@ //! turning an index's stored records into searchable documents is ecosystem-specific, and that sits //! behind the [`PackageIndexer`] seam, which each `peryx-ecosystem-*` crate implements. +mod access; mod context; mod engine; mod error; @@ -11,6 +12,7 @@ mod indexer; mod params; mod response; +pub use access::{SearchAccess, SearchAccessPattern}; pub use context::{IndexerCtx, SearchCtx}; pub use engine::{PackageSearch, truncate_to_chars}; pub use error::SearchError; diff --git a/crates/peryx-search/src/tests/integration_tests.rs b/crates/peryx-search/src/tests/integration_tests.rs index 1a2028a8..22ecc38b 100644 --- a/crates/peryx-search/src/tests/integration_tests.rs +++ b/crates/peryx-search/src/tests/integration_tests.rs @@ -4,7 +4,10 @@ use peryx_core::{Ecosystem, LexiconRegistry}; use super::{OCI_WORDS, Stores}; use crate::context::IndexerCtx; -use crate::{PackageDocument, PackageIndexer, PackageSearch, PackageSource, SearchError, SearchParams}; +use crate::{ + PackageDocument, PackageIndexer, PackageSearch, PackageSource, SearchAccess, SearchAccessPattern, SearchError, + SearchParams, +}; /// A stand-in ecosystem indexer that yields one document of a given ecosystem regardless of context. struct OneDoc { @@ -67,3 +70,74 @@ fn test_add_indexer_composes_both_ecosystems_with_localized_labels() { assert_eq!(pypi.type_label, "package"); assert_eq!(oci.type_label, "image"); } + +#[test] +fn test_authorized_search_filters_before_counting_and_pagination() { + let dir = tempfile::tempdir().unwrap(); + let stores = Stores::open(&dir); + let lexicons = LexiconRegistry::default(); + let mut search = PackageSearch::in_memory(); + search.add_indexer(Arc::new(AccessDocs)); + + for (case, patterns, expected) in [ + ("no patterns", vec![], (0, vec![])), + ("one pattern", vec![("private", "team/*")], (1, vec!["team/app"])), + ( + "union", + vec![("private", "team/*"), ("public", "*")], + (2, vec!["team/app"]), + ), + ] { + let response = search + .search_authorized( + &stores.ctx(&lexicons), + SearchParams { + page_size: 1, + ..SearchParams::default() + }, + &SearchAccess::new( + patterns + .into_iter() + .map(|(route, glob)| SearchAccessPattern { + route: route.to_owned(), + glob: glob.to_owned(), + }) + .collect(), + ), + ) + .unwrap(); + + assert_eq!( + ( + response.total, + response + .results + .iter() + .map(|result| result.normalized_name.as_str()) + .collect::>() + ), + expected, + "{case}" + ); + } +} + +struct AccessDocs; + +impl PackageIndexer for AccessDocs { + fn documents(&self, _ctx: &IndexerCtx<'_>) -> Result, SearchError> { + Ok([("private", "hidden"), ("private", "team/app"), ("public", "visible")] + .into_iter() + .map(|(route, name)| PackageDocument { + display_name: name.to_owned(), + normalized_name: name.to_owned(), + route: route.to_owned(), + index: route.to_owned(), + ecosystem: "pypi".to_owned(), + source: PackageSource::Cached, + summary: None, + text: name.to_owned(), + }) + .collect()) + } +} diff --git a/crates/peryx-web/Cargo.toml b/crates/peryx-web/Cargo.toml index e932e893..39f71436 100644 --- a/crates/peryx-web/Cargo.toml +++ b/crates/peryx-web/Cargo.toml @@ -20,6 +20,7 @@ ssr = [ "dep:leptos_axum", "dep:peryx-driver", "dep:peryx-http", + "dep:peryx-identity", "dep:peryx-search", "dep:peryx-storage", "dep:axum", @@ -36,6 +37,7 @@ leptos_router.workspace = true leptos_axum = { workspace = true, optional = true } peryx-driver = { workspace = true, optional = true } peryx-http = { workspace = true, optional = true } +peryx-identity = { workspace = true, optional = true } peryx-search = { workspace = true, optional = true } peryx-storage = { workspace = true, optional = true } axum = { workspace = true, optional = true } diff --git a/crates/peryx-web/src/data/search.rs b/crates/peryx-web/src/data/search.rs index f7b9da57..c4a3f1e7 100644 --- a/crates/peryx-web/src/data/search.rs +++ b/crates/peryx-web/src/data/search.rs @@ -17,7 +17,7 @@ pub async fn load_search( ) -> Result { #[cfg(feature = "ssr")] { - crate::ssr::search(&query, &source_type, page, page_size) + crate::ssr::search(&query, &source_type, page, page_size).await } #[cfg(all(not(feature = "ssr"), feature = "hydrate"))] { diff --git a/crates/peryx-web/src/data/simple.rs b/crates/peryx-web/src/data/simple.rs index 415d1466..6b3fc3b0 100644 --- a/crates/peryx-web/src/data/simple.rs +++ b/crates/peryx-web/src/data/simple.rs @@ -15,7 +15,7 @@ pub async fn load_projects(route: String) -> Result, String> { } #[cfg(feature = "ssr")] { - crate::ssr::projects(&route) + crate::ssr::projects(&route).await } #[cfg(all(not(feature = "ssr"), feature = "hydrate"))] { diff --git a/crates/peryx-web/src/ssr/mod.rs b/crates/peryx-web/src/ssr/mod.rs index 9e1fac9b..2d649638 100644 --- a/crates/peryx-web/src/ssr/mod.rs +++ b/crates/peryx-web/src/ssr/mod.rs @@ -12,3 +12,14 @@ pub use router::{UiState, ui_router}; pub use search::search; pub use simple::{layer_chunk, layer_members, manifest, project_view, projects}; pub use snapshot::{admin_snapshot, snapshot, stats}; + +async fn read_access(app: &peryx_driver::AppState) -> Result { + let headers = leptos_axum::extract::() + .await + .map_err(|err| format!("request headers: {err}"))?; + Ok(peryx_driver::access::ReadAccess::from_headers(app, &headers)) +} + +fn access_error(_: peryx_identity::Denial) -> String { + "read access denied".to_owned() +} diff --git a/crates/peryx-web/src/ssr/search.rs b/crates/peryx-web/src/ssr/search.rs index 0729eb9e..463a8570 100644 --- a/crates/peryx-web/src/ssr/search.rs +++ b/crates/peryx-web/src/ssr/search.rs @@ -10,7 +10,7 @@ use crate::model::UiSearchPage; /// /// # Errors /// Returns a user-visible message when search fails. -pub fn search(query: &str, source_type: &str, page: usize, page_size: usize) -> Result { +pub async fn search(query: &str, source_type: &str, page: usize, page_size: usize) -> Result { let app = expect_context::>(); let params = SearchParams { query: query.to_owned(), @@ -22,10 +22,17 @@ pub fn search(query: &str, source_type: &str, page: usize, page_size: usize) -> _ => 25, }, }; - let response = app - .search - .search(&app.search_ctx(), params) - .map_err(|err| format!("package search: {err}"))?; + let access = if app.indexes.iter().all(|index| index.acl.anonymous_read) { + None + } else { + Some(super::read_access(&app).await?.search_access(&app.indexes)) + }; + let response = if let Some(access) = access { + app.search.search_authorized(&app.search_ctx(), params, &access) + } else { + app.search.search(&app.search_ctx(), params) + } + .map_err(|err| format!("package search: {err}"))?; let value = serde_json::to_value(response).map_err(|err| format!("search result: {err}"))?; Ok(UiSearchPage::from_search(&value)) } diff --git a/crates/peryx-web/src/ssr/simple.rs b/crates/peryx-web/src/ssr/simple.rs index b42bd82c..475a716f 100644 --- a/crates/peryx-web/src/ssr/simple.rs +++ b/crates/peryx-web/src/ssr/simple.rs @@ -9,10 +9,18 @@ use peryx_driver::AppState; /// # Errors /// Returns a user-visible message when the index is unknown, its ecosystem is not wired in, or its /// project list cannot be read. -pub fn projects(route: &str) -> Result, String> { +pub async fn projects(route: &str) -> Result, String> { let app = expect_context::>(); let (position, driver) = resolve(&app, route)?; - driver.project_names(&app.serving, position) + if app.index_at(position).acl.anonymous_read { + return driver.project_names(&app.serving, position); + } + let access = super::read_access(&app).await?; + let access = access.for_index(app.index_at(position)); + access.authorize_any_project().map_err(super::access_error)?; + let mut names = driver.project_names(&app.serving, position)?; + names.retain(|project| access.authorize_project(project).is_ok()); + Ok(names) } /// One project's browse view: a file listing with metadata or a list of references, produced by the @@ -23,6 +31,7 @@ pub fn projects(route: &str) -> Result, String> { pub async fn project_view(route: &str, project: &str) -> Result, String> { let app = expect_context::>(); let (position, driver) = resolve(&app, route)?; + authorize_project(&app, position, project).await?; driver .browse_project(app.serving.clone(), position, project.to_owned()) .await @@ -35,6 +44,7 @@ pub async fn project_view(route: &str, project: &str) -> Result Result, String> { let app = expect_context::>(); let (position, driver) = resolve(&app, route)?; + authorize_project(&app, position, repo).await?; driver .manifest_view(app.serving.clone(), position, repo.to_owned(), reference.to_owned()) .await @@ -47,6 +57,7 @@ pub async fn manifest(route: &str, repo: &str, reference: &str) -> Result