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