Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

160 changes: 160 additions & 0 deletions crates/peryx-driver/src/access.rs
Original file line number Diff line number Diff line change
@@ -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<String>, now: i64 },
Bearer(Vec<Grant>),
}

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<Item = &'a str> {
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}"))
}
}
1 change: 1 addition & 0 deletions crates/peryx-driver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
93 changes: 93 additions & 0 deletions crates/peryx-driver/src/tests/access_tests.rs
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions crates/peryx-driver/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mod access_tests;
mod body_tests;
mod state_tests;
2 changes: 1 addition & 1 deletion crates/peryx-http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/peryx-http/src/handlers/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
56 changes: 41 additions & 15 deletions crates/peryx-http/src/handlers/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>, position: usize, uri: &axum::http::Uri) -> Response {
pub(super) async fn index_search(
state: Arc<AppState>,
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<Arc<AppState>>, OriginalUri(uri): OriginalUri) -> Response {
pub async fn search(State(state): State<Arc<AppState>>, 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<SearchAccess> {
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
Expand All @@ -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<AppState>, params: SearchParams) -> Response {
tokio::task::spawn_blocking(move || search_response(&state, params))
pub async fn search_response_offloaded(
state: Arc<AppState>,
params: SearchParams,
access: Option<SearchAccess>,
) -> 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 {
Expand Down
Loading
Loading