Skip to content

Commit ad45c67

Browse files
committed
httpd: Allow conditional access to private repos
1 parent 8e21485 commit ad45c67

12 files changed

Lines changed: 495 additions & 119 deletions

File tree

radicle-httpd/Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

radicle-httpd/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
2929
futures-util = { version = "0.3.32", default-features = false }
3030
hyper = { version = "1.6.0", default-features = false }
3131
infer = { version = "0.19.0" }
32+
ipnet = { version = "2.12.0", features = ["serde"] }
3233
lexopt = { version = "0.3.1" }
3334
lru = { version = "0.16.0" }
3435
mime_guess = { version = "2.0.5" }

radicle-httpd/src/api.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ pub(crate) mod query;
2222
mod v1;
2323

2424
use crate::api::error::Error;
25+
use crate::auth::HttpClientInfo;
2526
use crate::cache::Cache;
26-
use crate::Options;
27+
use crate::{AccessPolicy, Options};
2728

2829
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
2930
// This version has to be updated on every breaking change to the radicle-httpd API.
@@ -69,6 +70,7 @@ pub struct Context {
6970
profile: Arc<Profile>,
7071
cache: Option<Cache>,
7172
web_config: WebConfig,
73+
access_policy: Arc<AccessPolicy>,
7274
}
7375

7476
impl Context {
@@ -77,6 +79,7 @@ impl Context {
7779
profile: profile.clone(),
7880
cache: options.cache.map(Cache::new),
7981
web_config,
82+
access_policy: Arc::clone(&options.access_policy),
8083
}
8184
}
8285

@@ -139,14 +142,17 @@ impl Context {
139142

140143
/// Get a repository by RID, checking to make sure we're allowed to view it.
141144
#[allow(clippy::result_large_err)]
142-
pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
145+
pub fn repo(
146+
&self,
147+
rid: RepoId,
148+
client_info: &HttpClientInfo,
149+
) -> Result<(Repository, DocAt), error::Error> {
143150
let repo = self.profile.storage.repository(rid)?;
144151
let doc = repo.identity_doc()?;
145-
// Don't allow accessing private repos.
146-
if doc.visibility().is_private() {
147-
return Err(Error::NotFound);
148-
}
149-
Ok((repo, doc))
152+
self.access_policy
153+
.check(client_info.with_repo(rid, &doc))
154+
.then_some((repo, doc))
155+
.ok_or(Error::NotFound)
150156
}
151157

152158
/// Returns a reference to the thread-safe web configuration.
@@ -332,9 +338,6 @@ mod search {
332338
db: &Database,
333339
aliases: &Aliases,
334340
) -> Option<Self> {
335-
if info.doc.visibility().is_private() {
336-
return None;
337-
}
338341
let Ok(Some(index)) = info.doc.project().map(|p| p.name().find(q)) else {
339342
return None;
340343
};
@@ -584,6 +587,7 @@ mod tests {
584587
use radicle::identity::RepoId;
585588
use radicle::storage::{ReadRepository, ReadStorage};
586589

590+
use crate::auth::HttpClientInfo;
587591
use crate::test;
588592

589593
fn r(s: &str) -> &RefStr {
@@ -610,7 +614,7 @@ mod tests {
610614
let ctx = test::seed(tmp.path());
611615
let rid = RepoId::from_str(test::RID).unwrap();
612616

613-
let (repo, doc) = ctx.repo(rid).unwrap();
617+
let (repo, doc) = ctx.repo(rid, &HttpClientInfo::default()).unwrap();
614618
let info = ctx.repo_info(&repo, doc).unwrap();
615619

616620
assert!(info.refs.tags.is_empty());

radicle-httpd/src/api/v1/delegates.rs

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use radicle::storage::ReadStorage;
99
use crate::api::error::Error;
1010
use crate::api::query::{PaginationQuery, RepoQuery};
1111
use crate::api::Context;
12+
use crate::auth::HttpClientInfo;
1213
use crate::axum_extra::{Path, Query};
1314

1415
pub fn router(ctx: Context) -> Router {
@@ -23,6 +24,7 @@ async fn delegates_repos_handler(
2324
State(ctx): State<Context>,
2425
Path(did): Path<Did>,
2526
Query(qs): Query<PaginationQuery>,
27+
client_info: HttpClientInfo,
2628
) -> impl IntoResponse {
2729
let PaginationQuery {
2830
show,
@@ -35,31 +37,25 @@ async fn delegates_repos_handler(
3537
let web_config = ctx.web_config().read().await;
3638
let pinned = &web_config.pinned;
3739
let mut repos = match show {
38-
RepoQuery::All => storage
39-
.repositories()?
40-
.into_iter()
41-
.filter(|repo| repo.doc.visibility().is_public())
42-
.filter(|repo| repo.doc.delegates().iter().any(|d| *d == did))
43-
.collect::<Vec<_>>(),
40+
RepoQuery::All => storage.repositories()?,
4441
RepoQuery::Pinned => storage
4542
.repositories_by_id(pinned.repositories.iter())
46-
.filter_map(|result| match result {
47-
Ok(repo) => Some(repo),
48-
Err(e) => {
49-
tracing::warn!("Failed to load pinned repository: {}", e);
50-
None
51-
}
43+
.filter_map(|result| {
44+
result
45+
.inspect_err(|err| {
46+
tracing::warn!("Failed to load pinned repository: {err}");
47+
})
48+
.ok()
5249
})
53-
.filter(|repo| repo.doc.visibility().is_public())
54-
.filter(|repo| repo.doc.delegates().iter().any(|d| *d == did))
55-
.collect::<Vec<_>>(),
50+
.collect(),
5651
};
5752
repos.sort_by_key(|p| p.rid);
5853

5954
let infos = repos
6055
.into_iter()
56+
.filter(|repo| repo.doc.delegates().iter().any(|d| *d == did))
6157
.filter_map(|id| {
62-
let Ok((repo, doc)) = ctx.repo(id.rid) else {
58+
let Ok((repo, doc)) = ctx.repo(id.rid, &client_info) else {
6359
return None;
6460
};
6561
let Ok(repo_info) = ctx.repo_info(&repo, doc) else {

0 commit comments

Comments
 (0)