From 46394847d790d8a33b60861820b234422c8652ad Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 15 Oct 2026 20:24:27 +0300 Subject: [PATCH] knot2: pass who is asking into every read check Signed-off-by: dawn --- Cargo.lock | 1 + knot2/crates/knot-acl/Cargo.toml | 1 + knot2/crates/knot-acl/src/lib.rs | 7 +- knot2/crates/knot-acl/src/repos.rs | 7 +- knot2/crates/knot-acl/src/viewer.rs | 27 ++++ knot2/crates/knot-atproto/src/lib.rs | 12 +- knot2/crates/knot-pack/src/lib.rs | 43 +++++-- knot2/crates/knot-pack/tests/common/mod.rs | 10 +- knot2/crates/knot-pack/tests/git_client.rs | 31 ++--- .../crates/knot-pack/tests/h3_conformance.rs | 10 +- knot2/crates/knot-pack/tests/handle_owner.rs | 24 ++-- knot2/crates/knot-pack/tests/serving.rs | 10 +- knot2/crates/knot-pack/tests/soak.rs | 10 +- knot2/crates/knot-server/src/main.rs | 10 +- knot2/crates/knot-sim/src/harness.rs | 10 +- knot2/crates/knot-sim/tests/h3.rs | 10 +- knot2/crates/knot-sim/tests/lfs_roundtrip.rs | 10 +- knot2/crates/knot-ssh/src/exec.rs | 32 +++-- knot2/crates/knot-xrpc/src/atproto.rs | 56 +++++++-- knot2/crates/knot-xrpc/src/blobs.rs | 10 +- knot2/crates/knot-xrpc/src/branches.rs | 5 +- knot2/crates/knot-xrpc/src/collaborators.rs | 14 ++- knot2/crates/knot-xrpc/src/forks.rs | 29 +++-- knot2/crates/knot-xrpc/src/imports.rs | 58 +++++---- knot2/crates/knot-xrpc/src/invite_codes.rs | 5 +- knot2/crates/knot-xrpc/src/lfs.rs | 6 +- knot2/crates/knot-xrpc/src/lib.rs | 64 +++++++--- knot2/crates/knot-xrpc/src/merge.rs | 15 ++- knot2/crates/knot-xrpc/src/mint.rs | 9 +- knot2/crates/knot-xrpc/src/reads.rs | 73 ++++++++--- knot2/crates/knot-xrpc/src/receive.rs | 6 +- knot2/crates/knot-xrpc/src/repos.rs | 44 ++++--- knot2/crates/knot-xrpc/src/social.rs | 80 ++++++++---- knot2/crates/knot-xrpc/src/tests.rs | 118 +++++++++++++++--- knot2/crates/knot-xrpc/tests/common/mod.rs | 16 ++- knot2/justfile | 13 +- 36 files changed, 613 insertions(+), 273 deletions(-) create mode 100644 knot2/crates/knot-acl/src/viewer.rs diff --git a/Cargo.lock b/Cargo.lock index 1ba084c27..75d26c34b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5209,6 +5209,7 @@ dependencies = [ name = "knot-acl" version = "2.0.0" dependencies = [ + "knot-atproto", "knot-cob", "knot-cobs", "knot-git", diff --git a/knot2/crates/knot-acl/Cargo.toml b/knot2/crates/knot-acl/Cargo.toml index ed71a7db7..4da67a18b 100644 --- a/knot2/crates/knot-acl/Cargo.toml +++ b/knot2/crates/knot-acl/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] knot-types = { workspace = true } +knot-atproto = { workspace = true } knot-index = { workspace = true } knot-git = { workspace = true } knot-lfs = { workspace = true } diff --git a/knot2/crates/knot-acl/src/lib.rs b/knot2/crates/knot-acl/src/lib.rs index a25fe4f9e..95b4dc954 100644 --- a/knot2/crates/knot-acl/src/lib.rs +++ b/knot2/crates/knot-acl/src/lib.rs @@ -10,9 +10,14 @@ pub use knot_types::Decision; mod lfs; mod repos; +mod viewer; pub use lfs::{Lfs, RepoLfs, RepoLfsWriter}; -pub use repos::{ContributeGrant, PushGrant, ReadGrant, Repos, push_grant, read_grant, read_grant_by_path, Social}; +pub use repos::{ + ContributeGrant, PushGrant, ReadGrant, Repos, Social, push_grant, read_grant, + read_grant_by_path, +}; +pub use viewer::Viewer; pub trait Acl { fn is_admin(&self, who: &AccountDid) -> bool; diff --git a/knot2/crates/knot-acl/src/repos.rs b/knot2/crates/knot-acl/src/repos.rs index fc604f675..46f3ff776 100644 --- a/knot2/crates/knot-acl/src/repos.rs +++ b/knot2/crates/knot-acl/src/repos.rs @@ -6,7 +6,7 @@ use knot_types::{ AccountDid, ClonePath, ContributionPermission, KnotId, ObjectFormat, OwnerDid, RepoDid, }; -use crate::{Acl, can_push}; +use crate::{Acl, Viewer, can_push}; #[derive(Debug, Clone)] pub struct Repos { @@ -120,7 +120,7 @@ impl Social { } } -pub fn read_grant(index: &Index, repo: RepoDid) -> Resolved> { +pub fn read_grant(index: &Index, repo: RepoDid, _viewer: &Viewer) -> Resolved> { index .owner_of(&repo) .map(|owner| owner.map(|_| ReadGrant(repo))) @@ -130,9 +130,10 @@ pub fn read_grant_by_path( index: &Index, owner: &OwnerDid, path: &ClonePath, + viewer: &Viewer, ) -> Resolved> { match index.resolve_clone_path(owner, path) { - Resolved::Ready(Some(repo)) => read_grant(index, repo), + Resolved::Ready(Some(repo)) => read_grant(index, repo, viewer), Resolved::Ready(None) => Resolved::Ready(None), Resolved::Warming => Resolved::Warming, } diff --git a/knot2/crates/knot-acl/src/viewer.rs b/knot2/crates/knot-acl/src/viewer.rs new file mode 100644 index 000000000..a66b0b7e2 --- /dev/null +++ b/knot2/crates/knot-acl/src/viewer.rs @@ -0,0 +1,27 @@ +use knot_atproto::Authenticated; +use knot_types::AccountDid; + +// who is asking. the only ways in are a verified service jwt or ssh naming a key's account, never a bare did +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Viewer(Option); + +impl Viewer { + pub fn anonymous() -> Self { + Self(None) + } + + pub fn account(&self) -> Option<&AccountDid> { + self.0.as_ref() + } + + // `check-no-forged-viewer` keeps this out of everything but knot-ssh's exec + pub fn ssh_identified(account: AccountDid) -> Self { + Self(Some(account)) + } +} + +impl From for Viewer { + fn from(proof: Authenticated) -> Self { + Self(Some(proof.into_did())) + } +} diff --git a/knot2/crates/knot-atproto/src/lib.rs b/knot2/crates/knot-atproto/src/lib.rs index 741520953..55cf21314 100644 --- a/knot2/crates/knot-atproto/src/lib.rs +++ b/knot2/crates/knot-atproto/src/lib.rs @@ -641,10 +641,20 @@ impl Atproto { method: &Nsid, repo: &RepoDid, ) -> Result { - self.verify_addressed(token, method, repo, ReplayGuard::SingleUse) + self.verify_service_jwt_for_repo_guarded(token, method, repo, ReplayGuard::SingleUse) .await } + pub async fn verify_service_jwt_for_repo_guarded( + &self, + token: &ServiceJwt, + method: &Nsid, + repo: &RepoDid, + replay: ReplayGuard, + ) -> Result { + self.verify_addressed(token, method, repo, replay).await + } + async fn verify_addressed( &self, token: &ServiceJwt, diff --git a/knot2/crates/knot-pack/src/lib.rs b/knot2/crates/knot-pack/src/lib.rs index 4441d973d..598fc6893 100644 --- a/knot2/crates/knot-pack/src/lib.rs +++ b/knot2/crates/knot-pack/src/lib.rs @@ -25,7 +25,7 @@ use axum::extract::{DefaultBodyLimit, Path, Query, State}; use axum::http::{HeaderMap, header}; use axum::response::Response; use axum::routing::post; -use knot_acl::{ReadGrant, Repos}; +use knot_acl::{ReadGrant, Repos, Viewer}; use knot_git::Repo; use knot_messages::{Catalog, ErrorKey, FetchMessages}; use knot_resource::{PackSlots, SlotPermit}; @@ -304,6 +304,18 @@ pub enum RepoLookup { Unavailable, } +impl RepoLookup { + pub fn in_index(index: &knot_index::Index, target: &RepoTarget, viewer: &Viewer) -> Self { + match target { + RepoTarget::Did(did) => knot_acl::read_grant(index, did.clone(), viewer), + RepoTarget::OwnerPath(owner, path) => { + knot_acl::read_grant_by_path(index, owner, path, viewer) + } + } + .into() + } +} + impl From>> for RepoLookup { fn from(resolved: knot_index::Resolved>) -> Self { match resolved { @@ -315,7 +327,13 @@ impl From>> for RepoLookup { } pub trait RepoResolver: Send + Sync + 'static { - fn resolve(&self, target: &RepoTarget) -> RepoLookup; + fn resolve(&self, target: &RepoTarget, viewer: &Viewer) -> RepoLookup; +} + +pub fn index_resolver(index: Arc) -> Arc { + Arc::new(move |target: &RepoTarget, viewer: &Viewer| { + RepoLookup::in_index(&index, target, viewer) + }) } pub trait HandleResolver: Send + Sync + 'static { @@ -338,10 +356,10 @@ pub trait ReceiveAdvertiser: Send + Sync + 'static { impl RepoResolver for F where - F: Fn(&RepoTarget) -> RepoLookup + Send + Sync + 'static, + F: Fn(&RepoTarget, &Viewer) -> RepoLookup + Send + Sync + 'static, { - fn resolve(&self, target: &RepoTarget) -> RepoLookup { - self(target) + fn resolve(&self, target: &RepoTarget, viewer: &Viewer) -> RepoLookup { + self(target, viewer) } } @@ -530,16 +548,19 @@ async fn resolve_owner(state: &PackState, owner: &str) -> Result Result { let path = ClonePath::parse(name).ok_or(PackError::NotFound)?; - lookup_grant( - state - .resolver - .resolve(&RepoTarget::OwnerPath(owner.clone(), path)), - ) + lookup_grant(state.resolver.resolve( + &RepoTarget::OwnerPath(owner.clone(), path), + &Viewer::anonymous(), + )) } fn resolve_did(state: &PackState, did: &str) -> Result { let did = RepoDid::new(did).map_err(bad_path)?; - lookup_grant(state.resolver.resolve(&RepoTarget::Did(did))) + lookup_grant( + state + .resolver + .resolve(&RepoTarget::Did(did), &Viewer::anonymous()), + ) } fn open(state: &PackState, grant: &ReadGrant) -> Result { diff --git a/knot2/crates/knot-pack/tests/common/mod.rs b/knot2/crates/knot-pack/tests/common/mod.rs index 50dc4870f..bbb3d9087 100644 --- a/knot2/crates/knot-pack/tests/common/mod.rs +++ b/knot2/crates/knot-pack/tests/common/mod.rs @@ -243,10 +243,12 @@ pub fn incompressible(seed: u64, len: usize) -> Vec { } pub fn serve_dids() -> Arc { - Arc::new(|target: &RepoTarget| match target { - RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), - RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, - }) + Arc::new( + |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, + }, + ) } pub async fn spawn(router: Router, bind: &str) -> SocketAddr { diff --git a/knot2/crates/knot-pack/tests/git_client.rs b/knot2/crates/knot-pack/tests/git_client.rs index 3f4f94910..7be7e0e0e 100644 --- a/knot2/crates/knot-pack/tests/git_client.rs +++ b/knot2/crates/knot-pack/tests/git_client.rs @@ -119,19 +119,21 @@ async fn http_routing_resolves_owner_rkey_and_dot_git_and_404s_the_unhosted() { let owner = owner.clone(); let plain_did = plain_did.clone(); let literal_did = literal_did.clone(); - Arc::new(move |target: &RepoTarget| match target { - RepoTarget::OwnerPath(o, p) - if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "barnacle.git") => - { - RepoLookup::Hosted(knot_acl::ReadGrant::internal(literal_did.clone())) - } - RepoTarget::OwnerPath(o, p) - if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "anemone") => - { - RepoLookup::Hosted(knot_acl::ReadGrant::internal(plain_did.clone())) - } - _ => RepoLookup::Unhosted, - }) + Arc::new( + move |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::OwnerPath(o, p) + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "barnacle.git") => + { + RepoLookup::Hosted(knot_acl::ReadGrant::internal(literal_did.clone())) + } + RepoTarget::OwnerPath(o, p) + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "anemone") => + { + RepoLookup::Hosted(knot_acl::ReadGrant::internal(plain_did.clone())) + } + _ => RepoLookup::Unhosted, + }, + ) }; let addr = spawn( knot_pack::router( @@ -202,7 +204,8 @@ async fn http_clone_while_the_index_is_warming_is_unavailable_not_404() { let scan = tempfile::tempdir().unwrap(); let layout = Layout::new(scan.path()); - let resolver: Arc = Arc::new(|_target: &RepoTarget| RepoLookup::Unavailable); + let resolver: Arc = + Arc::new(|_target: &RepoTarget, _viewer: &knot_acl::Viewer| RepoLookup::Unavailable); let by_name = axum::http::Request::builder() .uri("/did:plc:nel/anemone/info/refs?service=git-upload-pack") diff --git a/knot2/crates/knot-pack/tests/h3_conformance.rs b/knot2/crates/knot-pack/tests/h3_conformance.rs index 702167beb..0fbaf0d45 100644 --- a/knot2/crates/knot-pack/tests/h3_conformance.rs +++ b/knot2/crates/knot-pack/tests/h3_conformance.rs @@ -51,10 +51,12 @@ fn object_set(dir: &Path) -> BTreeSet { } fn serve_dids() -> Arc { - Arc::new(|target: &RepoTarget| match target { - RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), - RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, - }) + Arc::new( + |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, + }, + ) } fn free_port() -> u16 { diff --git a/knot2/crates/knot-pack/tests/handle_owner.rs b/knot2/crates/knot-pack/tests/handle_owner.rs index 134b62aed..94f9fd7fb 100644 --- a/knot2/crates/knot-pack/tests/handle_owner.rs +++ b/knot2/crates/knot-pack/tests/handle_owner.rs @@ -26,17 +26,19 @@ impl HandleResolver for FakeHandles { fn repo_resolver() -> Arc { let owner = OwnerDid::new("did:plc:nel").unwrap(); let repo = RepoDid::new("did:plc:whelk").unwrap(); - Arc::new(move |target: &RepoTarget| match target { - RepoTarget::OwnerPath(o, p) - if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "squid") => - { - RepoLookup::Hosted(knot_acl::ReadGrant::internal(repo.clone())) - } - RepoTarget::Did(d) if *d == repo => { - RepoLookup::Hosted(knot_acl::ReadGrant::internal(d.clone())) - } - _ => RepoLookup::Unhosted, - }) + Arc::new( + move |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::OwnerPath(o, p) + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "squid") => + { + RepoLookup::Hosted(knot_acl::ReadGrant::internal(repo.clone())) + } + RepoTarget::Did(d) if *d == repo => { + RepoLookup::Hosted(knot_acl::ReadGrant::internal(d.clone())) + } + _ => RepoLookup::Unhosted, + }, + ) } fn build(layout: &Layout, handle_resolver: Option>) -> axum::Router { diff --git a/knot2/crates/knot-pack/tests/serving.rs b/knot2/crates/knot-pack/tests/serving.rs index 02ed6a874..b38e7d1b0 100644 --- a/knot2/crates/knot-pack/tests/serving.rs +++ b/knot2/crates/knot-pack/tests/serving.rs @@ -16,10 +16,12 @@ mod common; use common::pkt; fn serve_dids() -> Arc { - Arc::new(|target: &RepoTarget| match target { - RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), - RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, - }) + Arc::new( + |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, + }, + ) } fn commit_blob(repo: &Repo, path: &str, content: &[u8], parents: Vec) -> Oid { diff --git a/knot2/crates/knot-pack/tests/soak.rs b/knot2/crates/knot-pack/tests/soak.rs index 4ef3410ab..87654f525 100644 --- a/knot2/crates/knot-pack/tests/soak.rs +++ b/knot2/crates/knot-pack/tests/soak.rs @@ -13,10 +13,12 @@ mod common; use common::must; fn serve_dids() -> std::sync::Arc { - std::sync::Arc::new(|target: &RepoTarget| match target { - RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), - RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, - }) + std::sync::Arc::new( + |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, + }, + ) } const BLOB_BYTES: usize = 16 * 1024 * 1024; diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index 4d0a6d27d..0b9236f7a 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -713,15 +713,7 @@ async fn run(config: knot_config::Validated) -> anyhow::Result<()> { } let seq_state = Arc::clone(&xrpc_state); let (seq_repos, seq_knot) = (seq_state.repos.clone(), seq_state.knot_did.clone()); - let resolver: Arc = { - let index = Arc::clone(&index); - Arc::new(move |target: &knot_pack::RepoTarget| match target { - knot_pack::RepoTarget::Did(did) => knot_acl::read_grant(&index, did.clone()).into(), - knot_pack::RepoTarget::OwnerPath(owner, path) => { - knot_acl::read_grant_by_path(&index, owner, path).into() - } - }) - }; + let resolver = knot_pack::index_resolver(Arc::clone(&index)); let receive_advertiser = knot_xrpc::receive_advertiser(Arc::clone(&xrpc_state)); let handle_resolver: Arc = Arc::new(AtprotoHandleResolver { atproto: Arc::clone(&atproto), diff --git a/knot2/crates/knot-sim/src/harness.rs b/knot2/crates/knot-sim/src/harness.rs index 7e109119e..e79e637a7 100644 --- a/knot2/crates/knot-sim/src/harness.rs +++ b/knot2/crates/knot-sim/src/harness.rs @@ -881,15 +881,7 @@ fn assemble_router(parts: StateParts) -> (Router, FakeState) { catalog: Arc::new(knot_messages::Catalog::defaults()), rkeys: Default::default(), }); - let resolver: Arc = { - let index = Arc::clone(&index); - Arc::new(move |target: &knot_pack::RepoTarget| match target { - knot_pack::RepoTarget::Did(did) => knot_acl::read_grant(&index, did.clone()).into(), - knot_pack::RepoTarget::OwnerPath(owner, path) => { - knot_acl::read_grant_by_path(&index, owner, path).into() - } - }) - }; + let resolver = knot_pack::index_resolver(Arc::clone(&index)); ( knot_pack::router( knot_acl::Repos::new(layout), diff --git a/knot2/crates/knot-sim/tests/h3.rs b/knot2/crates/knot-sim/tests/h3.rs index 010615b13..e76133e85 100644 --- a/knot2/crates/knot-sim/tests/h3.rs +++ b/knot2/crates/knot-sim/tests/h3.rs @@ -126,10 +126,12 @@ fn extract_pack(response: &[u8]) -> Vec { } fn serve_dids() -> Arc { - Arc::new(|target: &RepoTarget| match target { - RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), - RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, - }) + Arc::new( + |target: &RepoTarget, _viewer: &knot_acl::Viewer| match target { + RepoTarget::Did(did) => RepoLookup::Hosted(knot_acl::ReadGrant::internal(did.clone())), + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, + }, + ) } async fn clone_over_h3(edge: &Edge, did: &RepoDid, tip: &str) -> Vec { diff --git a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs index 47751212a..873f31442 100644 --- a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs +++ b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs @@ -442,15 +442,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { imports_halt, )); - let resolver: Arc = { - let index = Arc::clone(&index); - Arc::new(move |target: &knot_pack::RepoTarget| match target { - knot_pack::RepoTarget::Did(did) => knot_acl::read_grant(&index, did.clone()).into(), - knot_pack::RepoTarget::OwnerPath(owner, path) => { - knot_acl::read_grant_by_path(&index, owner, path).into() - } - }) - }; + let resolver = knot_pack::index_resolver(Arc::clone(&index)); let advertiser = knot_xrpc::receive_advertiser(Arc::clone(&xrpc_state)); let (write_routes, advertisement) = knot_pack::edge_routes(knot_pack::EdgeConfig { receive: Some(Arc::clone(&advertiser)), diff --git a/knot2/crates/knot-ssh/src/exec.rs b/knot2/crates/knot-ssh/src/exec.rs index 87e6a3a81..97b1469e4 100644 --- a/knot2/crates/knot-ssh/src/exec.rs +++ b/knot2/crates/knot-ssh/src/exec.rs @@ -7,7 +7,7 @@ use std::time::Duration; use futures::StreamExt; use knot_acl::{ - KnotAcl, PushGrant, ReadGrant, RepoLfs, RepoLfsWriter, push_grant, read_grant, + KnotAcl, PushGrant, ReadGrant, RepoLfs, RepoLfsWriter, Viewer, push_grant, read_grant, read_grant_by_path, }; use knot_index::Resolved; @@ -111,11 +111,12 @@ fn parse_repo_path(raw: &str) -> Option { fn resolve_repo_ref( state: &Arc>, repo_ref: ResolvedRef, + viewer: &Viewer, ) -> RepoLookup { match repo_ref { - ResolvedRef::Did(did) => read_grant(&state.index, did), + ResolvedRef::Did(did) => read_grant(&state.index, did, viewer), ResolvedRef::OwnerPath(owner, candidates) => { - read_grant_by_path(&state.index, &owner, &candidates) + read_grant_by_path(&state.index, &owner, &candidates, viewer) } } .into() @@ -176,7 +177,9 @@ pub(crate) async fn run_exec( } } }; - let grant = match resolve_repo_ref(&state, resolved_ref) { + let viewer = + account_of(&state, &credential).map_or_else(Viewer::anonymous, Viewer::ssh_identified); + let grant = match resolve_repo_ref(&state, resolved_ref, &viewer) { RepoLookup::Hosted(grant) => grant, RepoLookup::Unhosted => { fail(channel, &state.catalog.ssh.repo_not_found.text()).await; @@ -776,18 +779,27 @@ pub(crate) async fn run_greeting( finish(channel, 0).await; } -async fn greeting_visitor( +fn account_of( state: &Arc>, credential: &Credential, -) -> Visitor { - let did = match credential { - Credential::Identified(did) => did.clone(), +) -> Option { + match credential { + Credential::Identified(did) => Some(did.clone()), Credential::Offered(key) => { match state.index.owner_of_key(key, state.atproto.now().seconds()) { - Resolved::Ready(Some(did)) => did, - _ => return Visitor::Unknown, + Resolved::Ready(Some(did)) => Some(did), + _ => None, } } + } +} + +async fn greeting_visitor( + state: &Arc>, + credential: &Credential, +) -> Visitor { + let Some(did) = account_of(state, credential) else { + return Visitor::Unknown; }; match knot_receive::resolve_handle(&state.atproto, &state.slots.resolve, &did).await { Some(handle) => Visitor::Named(format!("@{}", handle.as_str())), diff --git a/knot2/crates/knot-xrpc/src/atproto.rs b/knot2/crates/knot-xrpc/src/atproto.rs index 19eb17c44..03ac4a536 100644 --- a/knot2/crates/knot-xrpc/src/atproto.rs +++ b/knot2/crates/knot-xrpc/src/atproto.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::State; -use axum::http::{HeaderValue, StatusCode, header}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use cid::Cid; @@ -15,7 +15,7 @@ use crate::query::ValidatedQuery; use jacquard_repo::RepoError; use jacquard_repo::car::write_car_bytes; use jacquard_repo::storage::BlockStore; -use knot_acl::ReadGrant; +use knot_acl::{ReadGrant, Viewer}; use knot_git::Repo; use knot_index::Resolved; use knot_record::blocks::GitBlocks; @@ -26,10 +26,10 @@ use knot_types::{Handle, KnotHostname, RecordCid, RecordCollection, RepoDid, Uri use serde::Deserialize; use serde_json::Value; -use crate::XrpcState; use crate::error::XrpcError; use crate::query::{Limit, Offset}; use crate::run_blocking; +use crate::{Method, XrpcState}; const CAR_CONTENT_TYPE: &str = "application/vnd.ipld.car"; const LIST_RECORDS_DEFAULT: usize = 50; @@ -156,9 +156,23 @@ impl<'de> Deserialize<'de> for RepoSubject { } } +pub(crate) async fn resolve_for_reader( + state: &XrpcState, + subject: &RepoSubject, + headers: &HeaderMap, + method: &Method, +) -> Result { + let viewer = match subject { + RepoSubject::Did(did) => state.reader(headers, method, did).await?, + RepoSubject::Host(_) => Viewer::anonymous(), + }; + resolve(state, subject, &viewer) +} + pub(crate) fn resolve( state: &XrpcState, subject: &RepoSubject, + viewer: &Viewer, ) -> Result { let self_hosted = match subject { RepoSubject::Did(did) => *did == state.knot_repo, @@ -174,7 +188,7 @@ pub(crate) fn resolve( let RepoSubject::Did(did) = subject else { return Err(repo_not_found(subject.as_str())); }; - match knot_acl::read_grant(&state.index, did.clone()) { + match knot_acl::read_grant(&state.index, did.clone(), viewer) { Resolved::Ready(Some(grant)) => Ok(AtprotoRepo { repo: state.repos.open(&grant)?, did: grant.repo().clone(), @@ -252,9 +266,11 @@ const EXPORT_FRAME_WINDOW: usize = 5_000; pub(crate) async fn sync_get_repo( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let found = resolve(&state, ¶ms.did)?; + let found = resolve_for_reader(&state, ¶ms.did, &headers, &method).await?; let response_limit = state.byte_limits.response.get(); serve_car( found, @@ -355,9 +371,11 @@ pub(crate) struct DidParams { pub(crate) async fn sync_get_latest_commit( State(state): State>>, + headers: HeaderMap, + method: Method, axum::extract::Query(params): axum::extract::Query, ) -> Result { - let found = resolve(&state, ¶ms.did)?; + let found = resolve_for_reader(&state, ¶ms.did, &headers, &method).await?; let response_limit = state.byte_limits.response.get(); run_blocking(move || { let tip = tip_of(&found.repo, &found.did)?; @@ -379,9 +397,11 @@ pub(crate) struct SyncRecordParams { pub(crate) async fn sync_get_record( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let found = resolve(&state, ¶ms.did)?; + let found = resolve_for_reader(&state, ¶ms.did, &headers, &method).await?; let key = format!("{}/{}", params.collection, params.rkey); serve_car(found, state.byte_limits.response.get(), |found, tip| { let tree = chain::load_tree(&found.repo, tip); @@ -483,9 +503,11 @@ impl axum::extract::FromRequestParts for BlocksQuery { pub(crate) async fn sync_get_blocks( State(state): State>>, + headers: HeaderMap, + method: Method, params: BlocksQuery, ) -> Result { - let found = resolve(&state, ¶ms.did)?; + let found = resolve_for_reader(&state, ¶ms.did, &headers, &method).await?; let wanted = params.cids; serve_car(found, state.byte_limits.response.get(), |found, tip| { let store = found.blocks(tip); @@ -537,7 +559,7 @@ pub(crate) async fn sync_list_repos( let repos: Vec = page .iter() .filter_map(|did| { - resolve(&state, did) + resolve(&state, did, &Viewer::anonymous()) .and_then(|found| { tip_of(&found.repo, &found.did).and_then(|tip| { let head = chain::commit_cid(&chain::commit_bytes(&found.repo, &tip)?)?; @@ -595,10 +617,12 @@ fn next_offset(offset: usize, limit: usize, total: usize) -> Option { pub(crate) async fn sync_get_repo_status( State(state): State>>, + headers: HeaderMap, + method: Method, axum::extract::Query(params): axum::extract::Query, ) -> Result { let response_limit = state.byte_limits.response.get(); - match resolve(&state, ¶ms.did) { + match resolve_for_reader(&state, ¶ms.did, &headers, &method).await { Ok(found) => { run_blocking(move || { let rev = chain::tip(&found.repo)?.map(|tip| tip.rev.to_string()); @@ -647,9 +671,11 @@ pub(crate) struct RepoRecordParams { pub(crate) async fn repo_get_record( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let found = resolve(&state, ¶ms.repo)?; + let found = resolve_for_reader(&state, ¶ms.repo, &headers, &method).await?; let key = format!("{}/{}", params.collection, params.rkey); let uri = format!("at://{}/{key}", found.did); let wanted = params.cid; @@ -708,9 +734,11 @@ pub(crate) struct ListRecordsParams { pub(crate) async fn repo_list_records( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let found = resolve(&state, ¶ms.repo)?; + let found = resolve_for_reader(&state, ¶ms.repo, &headers, &method).await?; let prefix = format!("{}/", params.collection); let limit = params.limit; let reverse = params.reverse; @@ -806,9 +834,11 @@ pub(crate) struct RepoQuery { pub(crate) async fn repo_describe_repo( State(state): State>>, + headers: HeaderMap, + method: Method, axum::extract::Query(params): axum::extract::Query, ) -> Result { - let found = resolve(&state, ¶ms.repo)?; + let found = resolve_for_reader(&state, ¶ms.repo, &headers, &method).await?; let knot = found.did == state.knot_repo; let handle = if knot { state.knot_hostname.as_str().to_owned() diff --git a/knot2/crates/knot-xrpc/src/blobs.rs b/knot2/crates/knot-xrpc/src/blobs.rs index a9e2508c1..642916b63 100644 --- a/knot2/crates/knot-xrpc/src/blobs.rs +++ b/knot2/crates/knot-xrpc/src/blobs.rs @@ -20,10 +20,10 @@ use knot_types::{BlobCid, RecordAddress, RepoDid, Sha256Digest}; use serde::Deserialize; use serde_json::json; -use crate::XrpcState; use crate::error::XrpcError; use crate::query::{Limit, Offset, Since, ValidatedQuery}; use crate::run_blocking; +use crate::{Method, XrpcState}; pub(crate) const UPLOAD_BLOB_ROUTE: &str = "/xrpc/com.atproto.repo.uploadBlob"; pub(crate) const GET_BLOB_ROUTE: &str = "/xrpc/com.atproto.sync.getBlob"; @@ -155,9 +155,11 @@ pub(crate) struct GetBlobParams { pub(crate) async fn get_blob( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let found = crate::atproto::resolve(&state, ¶ms.did)?; + let found = crate::atproto::resolve_for_reader(&state, ¶ms.did, &headers, &method).await?; let lfs = state.lfs.as_ref().ok_or_else(store_absent)?; let oid = LfsOid::from_digest(params.cid.digest()); let store = found @@ -215,9 +217,11 @@ pub(crate) struct ListBlobsParams { pub(crate) async fn list_blobs( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let found = crate::atproto::resolve(&state, ¶ms.did)?; + let found = crate::atproto::resolve_for_reader(&state, ¶ms.did, &headers, &method).await?; let index = index_of(&state, found).await?; match index.coverage { Coverage::Whole => (), diff --git a/knot2/crates/knot-xrpc/src/branches.rs b/knot2/crates/knot-xrpc/src/branches.rs index 8fbaf6b30..d3953c169 100644 --- a/knot2/crates/knot-xrpc/src/branches.rs +++ b/knot2/crates/knot-xrpc/src/branches.rs @@ -43,9 +43,10 @@ pub(crate) async fn delete_branch( method: crate::Method, body: Bytes, ) -> Result { - let actor = state.authenticate_write(&headers, &method).await?; + let (actor, viewer) = + crate::actor_and_viewer(state.authenticate_write(&headers, &method).await?); let input: DeleteBranchInput = decode(&body)?; - let hosted = require_hosted(&state, input.repo)?; + let hosted = require_hosted(&state, input.repo, &viewer)?; let pushing = crate::authorize_push(&state, &actor, &hosted, BRANCH_DENIED).await?; let refname = input.branch.head_ref(); diff --git a/knot2/crates/knot-xrpc/src/collaborators.rs b/knot2/crates/knot-xrpc/src/collaborators.rs index 5e0566b2a..de47c513c 100644 --- a/knot2/crates/knot-xrpc/src/collaborators.rs +++ b/knot2/crates/knot-xrpc/src/collaborators.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::response::Response; -use knot_acl::{KnotAcl, ReadGrant, can_manage_collaborators}; +use knot_acl::{KnotAcl, ReadGrant, Viewer, can_manage_collaborators}; use knot_cob::{CobHome, CobStore}; use knot_cobs::{CollaboratorsChange, CollaboratorsCob, Grant, Removal}; use knot_index::Resolved; @@ -29,12 +29,16 @@ fn registered_owner( fn require_owner( state: &XrpcState, actor: &AccountDid, + viewer: &Viewer, repo: &RepoDid, ) -> Result<(knot_types::OwnerDid, ReadGrant), XrpcError> { let owner = registered_owner(state, repo)?; let acl = KnotAcl::new(&state.admins, state.admission, &state.index); if can_manage_collaborators(&acl, actor, repo).is_allowed() { - Ok((owner, crate::reads::require_hosted(state, repo.clone())?)) + Ok(( + owner, + crate::reads::require_hosted(state, repo.clone(), viewer)?, + )) } else { Err(XrpcError::forbidden( "only repository owner may manage collaborators", @@ -45,10 +49,11 @@ fn require_owner( pub(crate) async fn collaborator_add( state: &Arc>, actor: &AccountDid, + viewer: &Viewer, repo: &RepoDid, subject: AccountDid, ) -> Result<(), XrpcError> { - let (owner, grant) = require_owner(state, actor, repo)?; + let (owner, grant) = require_owner(state, actor, viewer, repo)?; let now = state.now(); commit_collaborators( state, @@ -120,10 +125,11 @@ fn owner_unmoved( pub(crate) async fn collaborator_remove( state: &Arc>, actor: &AccountDid, + viewer: &Viewer, repo: &RepoDid, subject: AccountDid, ) -> Result<(), XrpcError> { - let (owner, grant) = require_owner(state, actor, repo)?; + let (owner, grant) = require_owner(state, actor, viewer, repo)?; commit_collaborators( state, diff --git a/knot2/crates/knot-xrpc/src/forks.rs b/knot2/crates/knot-xrpc/src/forks.rs index 28fabe434..b1f51b8ff 100644 --- a/knot2/crates/knot-xrpc/src/forks.rs +++ b/knot2/crates/knot-xrpc/src/forks.rs @@ -9,7 +9,7 @@ use http::HeaderMap; use serde::{Deserialize, Serialize}; use url::Url; -use knot_acl::{PushGrant, ReadGrant}; +use knot_acl::{PushGrant, ReadGrant, Viewer}; use knot_git::{Filter, GitError, Haves, RefClass, RefUpdate, Repo, Staging, Wants}; use knot_index::Resolved; use knot_pack::{FetchError, HaveOids, PackLimits, UpstreamRefs, WantOids}; @@ -93,10 +93,13 @@ impl LocalPath { fn resolve_local( state: &XrpcState, path: &LocalPath, + viewer: &Viewer, ) -> Result { let grant = match path { - LocalPath::Did(did) => require_hosted(state, did.clone()), - LocalPath::Named { owner, name } => crate::merge::resolve_by_name(state, owner, name), + LocalPath::Did(did) => require_hosted(state, did.clone(), viewer), + LocalPath::Named { owner, name } => { + crate::merge::resolve_by_name(state, owner, name, viewer) + } }?; match state.index.visibility_of(grant.repo()) { Resolved::Ready(Some(Visibility::Public)) => Ok(grant), @@ -108,12 +111,13 @@ fn resolve_local( pub(crate) fn resolve_upstream( state: &XrpcState, source: &SourceUrl, + viewer: &Viewer, ) -> Result { let url = source.as_url(); if url_authority(url) == state.knot_authority() { return LocalPath::from_segments(&path_segments(url)) .map_err(|reason| XrpcError::invalid_request(format!("fork source {reason}"))) - .and_then(|path| resolve_local(state, &path)) + .and_then(|path| resolve_local(state, &path, viewer)) .map(Upstream::Local); } Ok(Upstream::Remote(url.clone())) @@ -138,12 +142,13 @@ impl ForkOrigin { fn resolve_origin( state: &XrpcState, origin: &ForkOrigin, + viewer: &Viewer, ) -> Result { match origin { - ForkOrigin::Source(source) => resolve_upstream(state, source), + ForkOrigin::Source(source) => resolve_upstream(state, source, viewer), ForkOrigin::File(url) => LocalPath::parse_trailing(url) .map_err(|reason| XrpcError::internal(format!("stored fork origin {reason}"))) - .and_then(|path| resolve_local(state, &path)) + .and_then(|path| resolve_local(state, &path, viewer)) .map(Upstream::Local), } } @@ -158,8 +163,9 @@ impl ForkSource { pub(crate) async fn resolve( state: &XrpcState, origin: &SourceUrl, + viewer: &Viewer, ) -> Result { - let upstream = resolve_upstream(state, origin)?; + let upstream = resolve_upstream(state, origin, viewer)?; let prefixes = ["HEAD", "refs/heads/", "refs/tags/"] .iter() .map(|prefix| prefix.to_string()) @@ -493,6 +499,7 @@ async fn pull_upstream_branch( pushing: &PushGrant, source: SourceRef<'_>, target: TargetRef<'_>, + viewer: &Viewer, ) -> Result { let branch = source.get(); let target = target.get(); @@ -504,7 +511,7 @@ async fn pull_upstream_branch( }) .await?; - let upstream = resolve_origin(state, &fork.origin)?; + let upstream = resolve_origin(state, &fork.origin, viewer)?; let refs = upstream_refs(state, &upstream, vec![branch.as_str().to_string()]).await?; if refs.object_format != fork.object_format { return Err(XrpcError::conflict(format!( @@ -572,9 +579,10 @@ pub(crate) async fn fork_sync( method: crate::Method, body: Bytes, ) -> Result { - let actor = state.authenticate_write(&headers, &method).await?; + let (actor, viewer) = + crate::actor_and_viewer(state.authenticate_write(&headers, &method).await?); let input: ForkSyncInput = decode(&body)?; - let hosted = require_hosted(&state, input.repo)?; + let hosted = require_hosted(&state, input.repo, &viewer)?; let pushing = crate::authorize_push(&state, &actor, &hosted, FORK_DENIED).await?; let branch = input.branch.head_ref(); let sync = pull_upstream_branch( @@ -582,6 +590,7 @@ pub(crate) async fn fork_sync( &pushing, SourceRef::new(&branch), TargetRef::new(&branch), + &viewer, ) .await?; if sync.old != Some(sync.new) diff --git a/knot2/crates/knot-xrpc/src/imports.rs b/knot2/crates/knot-xrpc/src/imports.rs index fd0b9c37e..c9b065d52 100644 --- a/knot2/crates/knot-xrpc/src/imports.rs +++ b/knot2/crates/knot-xrpc/src/imports.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use knot_acl::ReadGrant; +use knot_acl::{ReadGrant, Viewer}; use knot_git::RefRecord; use knot_index::Resolved; use knot_pack::UpstreamRefs; @@ -131,12 +131,12 @@ impl ImportJob { } } pub struct ImportQueue { - wake: mpsc::UnboundedSender, + wake: mpsc::UnboundedSender, processed: AtomicUsize, } impl ImportQueue { - pub fn channel() -> (Arc, mpsc::UnboundedReceiver) { + pub fn channel() -> (Arc, mpsc::UnboundedReceiver) { let (wake, jobs) = mpsc::unbounded_channel(); ( Arc::new(Self { @@ -147,8 +147,8 @@ impl ImportQueue { ) } - pub(crate) fn wake(&self, repo: RepoDid) { - let _ = self.wake.send(repo); + pub(crate) fn wake(&self, grant: ReadGrant) { + let _ = self.wake.send(grant); } // how many wakes the runner has finished processing; tests await this instead of @@ -206,7 +206,7 @@ pub(crate) async fn retry_failed( return Ok(false); } } - let upstream = ForkSource::resolve(state, source).await?; + let upstream = ForkSource::resolve(state, source, &Viewer::anonymous()).await?; { let _guard = state.cob_locks.repo(repo); if !ImportJob::read(&repo_path).is_some_and(|job| job.is_ok_and(|job| retryable(job.state))) @@ -215,18 +215,18 @@ pub(crate) async fn retry_failed( } stage(state, grant, source, kind, &upstream.refs)?; } - state.imports.wake(repo.clone()); + state.imports.wake(grant.clone()); Ok(true) } async fn populate_from( state: &Arc>, - repo: &RepoDid, + grant: &ReadGrant, upstream: &ForkSource, kind: SourceKind, ) -> Result, crate::XrpcError> { let repos = state.repos.clone(); - let placed = ReadGrant::internal(repo.clone()); + let placed = grant.clone(); let wanted = upstream.refs.object_format; let held = crate::run_blocking(move || { repos @@ -240,7 +240,7 @@ async fn populate_from( "the source changed its object format after this repository was created", )); } - crate::repos::populate(state, repo, upstream, kind).await + crate::repos::populate(state, grant, upstream, kind).await } // the job keeps its previous on-disk state when this write fails, so the log line is the @@ -268,10 +268,10 @@ pub(crate) fn hosted(state: &XrpcState, repo: &R pub(crate) async fn run_one( state: &Arc>, - repo: &RepoDid, + grant: &ReadGrant, ) { - let grant = ReadGrant::internal(repo.clone()); - let Ok(repo_path) = state.repos.path(&grant) else { + let repo = grant.repo(); + let Ok(repo_path) = state.repos.path(grant) else { return; }; let mut job = { @@ -307,16 +307,19 @@ pub(crate) async fn run_one( job }; let source = job.source.clone(); - let upstream = match job.refs.take() { - Some(refs) => crate::forks::resolve_upstream(state, &source).map(|upstream| ForkSource { - origin: source.clone(), - upstream, - refs: refs.into(), - }), - None => ForkSource::resolve(state, &source).await, - }; + let upstream = + match job.refs.take() { + Some(refs) => crate::forks::resolve_upstream(state, &source, &Viewer::anonymous()).map( + |upstream| ForkSource { + origin: source.clone(), + upstream, + refs: refs.into(), + }, + ), + None => ForkSource::resolve(state, &source, &Viewer::anonymous()).await, + }; let outcome = match upstream { - Ok(upstream) => populate_from(state, repo, &upstream, job.kind).await, + Ok(upstream) => populate_from(state, grant, &upstream, job.kind).await, Err(error) => Err(error), }; let _guard = state.cob_locks.repo(repo); @@ -346,6 +349,7 @@ pub(crate) async fn run_one( if landed { // the arrived refs go onto the record surface the way a push would let state = Arc::clone(state); + let grant = grant.clone(); let _ = crate::run_blocking(move || { let git = state.repos.open(&grant)?; crate::refrecords::sync_refs(&state, &git, grant.repo(), &[]); @@ -402,26 +406,26 @@ pub(crate) fn recover_jobs(state: &XrpcState) -> pub(crate) fn recover(state: &XrpcState) { recover_jobs(state) .into_iter() - .for_each(|repo| state.imports.wake(repo)); + .for_each(|repo| state.imports.wake(ReadGrant::internal(repo))); } const MAX_CONCURRENT_IMPORTS: usize = 4; pub async fn run( state: Arc>, - mut wake: mpsc::UnboundedReceiver, + mut wake: mpsc::UnboundedReceiver, mut shutdown: watch::Receiver, ) { recover(&state); let mut workers = JoinSet::new(); loop { tokio::select! { - repo = wake.recv(), if workers.len() < MAX_CONCURRENT_IMPORTS => match repo { - Some(repo) => { + grant = wake.recv(), if workers.len() < MAX_CONCURRENT_IMPORTS => match grant { + Some(grant) => { let state = Arc::clone(&state); let queue = Arc::clone(&state.imports); workers.spawn(async move { - run_one(&state, &repo).await; + run_one(&state, &grant).await; queue.processed.fetch_add(1, Ordering::SeqCst); }); } diff --git a/knot2/crates/knot-xrpc/src/invite_codes.rs b/knot2/crates/knot-xrpc/src/invite_codes.rs index 4c5acdb33..f04486fe1 100644 --- a/knot2/crates/knot-xrpc/src/invite_codes.rs +++ b/knot2/crates/knot-xrpc/src/invite_codes.rs @@ -72,7 +72,10 @@ pub(crate) async fn redeem_invite_code( method: crate::Method, body: Bytes, ) -> Result { - let actor = state.authenticate_write(&headers, &method).await?; + let actor = state + .authenticate_write(&headers, &method) + .await? + .into_did(); let CodeInput { code } = decode(&body)?; let now = state.now(); diff --git a/knot2/crates/knot-xrpc/src/lfs.rs b/knot2/crates/knot-xrpc/src/lfs.rs index 537db4c2a..382db7ed8 100644 --- a/knot2/crates/knot-xrpc/src/lfs.rs +++ b/knot2/crates/knot-xrpc/src/lfs.rs @@ -9,7 +9,7 @@ use axum::extract::{DefaultBodyLimit, Path, Request, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; -use knot_acl::{Lfs, PushGrant, ReadGrant, RepoLfs, RepoLfsWriter}; +use knot_acl::{Lfs, PushGrant, ReadGrant, RepoLfs, RepoLfsWriter, Viewer}; use knot_lfs::{ BATCH_MEDIA_TYPE, BatchAction, BatchActions, BatchObject, BatchObjectError, BatchOperation, BatchRequest, BatchResponse, BatchResponseObject, ClaimedSize, LfsError, LfsOid, LfsSize, @@ -165,7 +165,7 @@ fn resolve_did( state: &XrpcState, segment: &crate::RepoDidSegment, ) -> Result> { - crate::resolve_repo_did(state, segment).map_err(lfs_error) + crate::resolve_repo_did(state, segment, &Viewer::anonymous()).map_err(lfs_error) } async fn resolve_named( @@ -173,7 +173,7 @@ async fn resolve_named( owner: &crate::OwnerSegment, name: &crate::RepoNameSegment, ) -> Result> { - crate::resolve_repo_named(state, owner, name) + crate::resolve_repo_named(state, owner, name, &Viewer::anonymous()) .await .map_err(lfs_error) } diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index f6f1ec939..4ca43ee1a 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -68,10 +68,9 @@ use http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION}; use serde::de::DeserializeOwned; use serde_json::json; -use knot_acl::{PushGrant, ReadGrant, Repos}; -use knot_atproto::{Atproto, AtprotoError, ServiceJwt}; - use crate::legacy_admin::LegacyAdminSecret; +use knot_acl::{PushGrant, ReadGrant, Repos, Viewer}; +use knot_atproto::{Atproto, AtprotoError, Authenticated, ServiceJwt}; use knot_events::{EventLog, SubscriberGate}; pub use knot_git::ArchiveLimit; use knot_index::{Index, Resolved}; @@ -238,12 +237,11 @@ impl XrpcState { &self, headers: &HeaderMap, method: &Method, - ) -> Result { + ) -> Result { let token = bearer(headers)?; self.atproto .verify_service_jwt(&token, method.nsid()) .await - .map(knot_atproto::Authenticated::into_did) .map_err(map_verify_error) } @@ -252,22 +250,49 @@ impl XrpcState { headers: &HeaderMap, method: &Method, repo: &RepoDid, - ) -> Result { + ) -> Result { let token = bearer(headers)?; self.atproto .verify_service_jwt_for_repo(&token, method.nsid(), repo) .await - .map(knot_atproto::Authenticated::into_did) .map_err(map_verify_error) } + // a bad token errors instead of reading as anonymous so the caller knows it's broken + pub(crate) async fn reader( + &self, + headers: &HeaderMap, + method: &Method, + repo: &RepoDid, + ) -> Result { + if !headers.contains_key(AUTHORIZATION) { + return Ok(Viewer::anonymous()); + } + let token = bearer(headers)?; + let replay = knot_atproto::ReplayGuard::ReusableUntilExpiry; + let addressed_to_repo = knot_atproto::token_audience(&token).is_ok_and(|aud| aud == *repo); + let proof = match addressed_to_repo { + true => { + self.atproto + .verify_service_jwt_for_repo_guarded(&token, method.nsid(), repo, replay) + .await + } + false => { + self.atproto + .verify_service_jwt_guarded(&token, method.nsid(), replay) + .await + } + }; + proof.map(Viewer::from).map_err(map_verify_error) + } + pub(crate) async fn authenticate_write( &self, headers: &HeaderMap, method: &Method, - ) -> Result { + ) -> Result { let actor = self.authenticate(headers, method).await?; - admit_write(self, &actor)?; + admit_write(self, actor.did())?; Ok(actor) } @@ -276,9 +301,9 @@ impl XrpcState { headers: &HeaderMap, method: &Method, repo: &RepoDid, - ) -> Result { + ) -> Result { let actor = self.authenticate_for_repo(headers, method, repo).await?; - admit_write(self, &actor)?; + admit_write(self, actor.did())?; Ok(actor) } @@ -326,7 +351,7 @@ impl XrpcState { knot_atproto::ReplayGuard::ReusableUntilExpiry, ) .await - .map(knot_atproto::Authenticated::into_did) + .map(Authenticated::into_did) .map_err(map_verify_error) } } @@ -702,6 +727,10 @@ pub(crate) async fn authenticate_and_authorize_push( authorize_push(state, &actor, read, denied).await } +pub(crate) fn actor_and_viewer(proof: Authenticated) -> (AccountDid, Viewer) { + (proof.did().clone(), Viewer::from(proof)) +} + pub(crate) async fn run_blocking(task: F) -> Result where F: FnOnce() -> Result + Send + 'static, @@ -752,11 +781,12 @@ pub(crate) struct RepoPathParams { pub(crate) fn resolve_repo_did( state: &XrpcState, segment: &RepoDidSegment, + viewer: &Viewer, ) -> Result { let raw = segment.as_str(); let trimmed = raw.strip_suffix(".git").unwrap_or(raw); let did = RepoDid::new(trimmed).map_err(|_| XrpcError::not_found("Repository not found"))?; - git_grant(knot_acl::read_grant(&state.index, did)) + git_grant(knot_acl::read_grant(&state.index, did, viewer)) } fn git_grant(resolved: Resolved>) -> Result { @@ -773,11 +803,17 @@ pub(crate) async fn resolve_repo_named( state: &XrpcState, owner: &OwnerSegment, name: &RepoNameSegment, + viewer: &Viewer, ) -> Result { let owner = resolve_owner_segment(state, owner).await?; let path = ClonePath::parse(name.as_str()) .ok_or_else(|| XrpcError::not_found("Repository not found"))?; - git_grant(knot_acl::read_grant_by_path(&state.index, &owner, &path)) + git_grant(knot_acl::read_grant_by_path( + &state.index, + &owner, + &path, + viewer, + )) } async fn resolve_owner_segment( diff --git a/knot2/crates/knot-xrpc/src/merge.rs b/knot2/crates/knot-xrpc/src/merge.rs index a9f8a4e8e..b847f3c73 100644 --- a/knot2/crates/knot-xrpc/src/merge.rs +++ b/knot2/crates/knot-xrpc/src/merge.rs @@ -4,10 +4,10 @@ use axum::Json; use axum::body::Bytes; use axum::extract::State; use axum::response::{IntoResponse, Response}; -use http::StatusCode; +use http::{HeaderMap, StatusCode}; use serde::{Deserialize, Serialize}; -use knot_acl::ReadGrant; +use knot_acl::{ReadGrant, Viewer}; use knot_git::Repo; use knot_runtime::{Clock, HttpTransport}; use knot_types::{ @@ -15,11 +15,11 @@ use knot_types::{ RepoRkey, }; -use crate::XrpcState; use crate::decode; use crate::error::XrpcError; use crate::query::RepoArg; -use crate::reads::{repo_not_found, require_hosted, resolve_repo}; +use crate::reads::{repo_not_found, require_readable, resolve_repo}; +use crate::{Method, XrpcState}; pub(crate) const MERGE_CHECK_ROUTE: &str = "/xrpc/org.tangled.git.mergeCheck"; pub(crate) const CONFLICT_MESSAGE: &str = "patch can't be applied cleanly"; @@ -80,10 +80,11 @@ pub(crate) fn resolve_by_name( state: &XrpcState, owner: &OwnerDid, name: &RepoName, + viewer: &Viewer, ) -> Result { let rkey = RepoRkey::new(name.as_str()).map_err(|_| repo_not_found())?; let owner = owner.clone(); - resolve_repo(state, &RepoArg::OwnerRkey { owner, rkey }) + resolve_repo(state, &RepoArg::OwnerRkey { owner, rkey }, viewer) } pub(crate) fn branch_tip(repo: &Repo, refname: &RefName) -> Result { @@ -93,10 +94,12 @@ pub(crate) fn branch_tip(repo: &Repo, refname: &RefName) -> Result( State(state): State>>, + headers: HeaderMap, + method: Method, body: Bytes, ) -> Result { let input: MergeCheckInput = decode(&body)?; - let grant = require_hosted(&state, input.repo)?; + let grant = require_readable(&state, &headers, &method, input.repo).await?; let checked = crate::social::check_patch_merge(&state, &grant, &input.patch, input.branch).await?; Ok(check_response(match checked { diff --git a/knot2/crates/knot-xrpc/src/mint.rs b/knot2/crates/knot-xrpc/src/mint.rs index 6e5917e52..b80170d4c 100644 --- a/knot2/crates/knot-xrpc/src/mint.rs +++ b/knot2/crates/knot-xrpc/src/mint.rs @@ -46,11 +46,12 @@ pub(crate) async fn mint_token( lxm, exp, } = decode(&body)?; - let hosted = require_hosted(&state, repo)?; - let repo = hosted.repo(); - let actor = state - .authenticate_write_for_repo(&headers, &method, repo) + let authed = state + .authenticate_write_for_repo(&headers, &method, &repo) .await?; + let (actor, viewer) = crate::actor_and_viewer(authed); + let hosted = require_hosted(&state, repo, &viewer)?; + let repo = hosted.repo(); authorize_push(&state, &actor, &hosted, MINT_DENIED).await?; let now = state.now(); diff --git a/knot2/crates/knot-xrpc/src/reads.rs b/knot2/crates/knot-xrpc/src/reads.rs index 177b86ed5..2569965d0 100644 --- a/knot2/crates/knot-xrpc/src/reads.rs +++ b/knot2/crates/knot-xrpc/src/reads.rs @@ -13,7 +13,7 @@ use tokio::time::timeout; use tower::ServiceExt; use tower_http::services::ServeFile; -use knot_acl::{ReadGrant, Repos}; +use knot_acl::{ReadGrant, Repos, Viewer}; use knot_git::{ ArchiveFormat, BinaryBudget, BinaryDiff, BranchInfo, BranchTip, Commit, EntryKind, FilePatch, GitError, Identity, LineOp, LogLimit, LogSkip, PatchBody, PatchStatus, RefClass, Repo, @@ -29,7 +29,7 @@ use crate::query::{ BranchArg, Limit, Offset, RepoArg, Revspec, TagArg, Total, TreePath, ValidatedQuery, next_cursor, }; -use crate::{XrpcState, run_blocking}; +use crate::{Method, XrpcState, run_blocking}; pub(crate) const TREE_ROUTE: &str = "/xrpc/org.tangled.git.getTree"; pub(crate) const LOG_ROUTE: &str = "/xrpc/org.tangled.git.listCommits"; @@ -113,22 +113,34 @@ pub(crate) fn warming() -> XrpcError { pub(crate) fn require_hosted( state: &XrpcState, repo: RepoDid, + viewer: &Viewer, ) -> Result { - match knot_acl::read_grant(&state.index, repo) { + match knot_acl::read_grant(&state.index, repo, viewer) { Resolved::Ready(Some(grant)) => Ok(grant), Resolved::Ready(None) => Err(repo_not_found()), Resolved::Warming => Err(warming()), } } +pub(crate) async fn require_readable( + state: &XrpcState, + headers: &HeaderMap, + method: &Method, + repo: RepoDid, +) -> Result { + let viewer = state.reader(headers, method, &repo).await?; + require_hosted(state, repo, &viewer) +} + pub(crate) fn resolve_repo( state: &XrpcState, repo: &RepoArg, + viewer: &Viewer, ) -> Result { match repo { - RepoArg::Did(did) => require_hosted(state, did.clone()), + RepoArg::Did(did) => require_hosted(state, did.clone(), viewer), RepoArg::OwnerRkey { owner, rkey } => match state.index.resolve_repo(owner, rkey) { - Resolved::Ready(Some(did)) => require_hosted(state, did), + Resolved::Ready(Some(did)) => require_hosted(state, did, viewer), Resolved::Ready(None) => Err(repo_not_found()), Resolved::Warming => Err(warming()), }, @@ -290,9 +302,11 @@ struct TreeOut { pub(crate) async fn repo_tree( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let repos = state.repos.clone(); let limit = state.byte_limits.response.get(); let tree_deadline = state.budgets.tree_last_commit.get().deadline(); @@ -404,12 +418,14 @@ fn commit_touches(repo: &Repo, commit: &Commit, path: &TreePath) -> Result( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { if params.refspec.as_str().is_empty() { return Err(XrpcError::invalid_request("missing ref parameter")); } - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let limit = params.limit.get(); let repos = state.repos.clone(); let response_limit = state.byte_limits.response.get(); @@ -507,9 +523,11 @@ struct BranchesOut { pub(crate) async fn repo_branches( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let offset = params.cursor; let limit = params.limit; let repos = state.repos.clone(); @@ -563,9 +581,11 @@ struct BranchOut { pub(crate) async fn repo_branch( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let Some(name) = params.name.get().cloned() else { return Err(XrpcError::invalid_request("missing name parameter")); }; @@ -665,9 +685,11 @@ struct TagsOut { pub(crate) async fn repo_tags( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let offset = params.cursor; let limit = params.limit; let repos = state.repos.clone(); @@ -708,9 +730,11 @@ pub(crate) struct TagParams { pub(crate) async fn repo_tag( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let Some(name) = params.tag.get().cloned() else { return Err(XrpcError::invalid_request("missing tag parameter")); }; @@ -777,10 +801,11 @@ fn serve_blob(headers: &HeaderMap, contents: Vec) -> Response { pub(crate) async fn repo_blob( State(state): State>>, + method: Method, ValidatedQuery(params): ValidatedQuery, headers: HeaderMap, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; if params.path.as_str().is_empty() { return Err(XrpcError::invalid_request("missing path parameter")); } @@ -854,9 +879,11 @@ fn entry_not_found() -> XrpcError { pub(crate) async fn repo_entry( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; if params.path.as_str().is_empty() { return Err(XrpcError::invalid_request("missing path parameter")); } @@ -970,12 +997,14 @@ struct BlameOut { pub(crate) async fn repo_blame( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { if params.refspec.as_str().is_empty() { return Err(XrpcError::invalid_request("missing ref parameter")); } - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; if params.path.as_str().is_empty() { return Err(XrpcError::invalid_request("missing path parameter")); } @@ -1271,6 +1300,8 @@ struct DiffOut { pub(crate) async fn repo_diff( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { if params.base.as_str().is_empty() { @@ -1279,7 +1310,7 @@ pub(crate) async fn repo_diff( if params.head.as_str().is_empty() { return Err(XrpcError::invalid_request("missing head parameter")); } - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let repos = state.repos.clone(); let limit = state.byte_limits.response.get(); run_blocking(move || { @@ -1503,6 +1534,8 @@ fn reconcile_if_range(request: &mut Request, etag: &str, modified_secs: i64) { pub(crate) async fn repo_archive( State(state): State>>, + headers: HeaderMap, + method: Method, mut request: Request, ) -> Result { let params = Query::::try_from_uri(request.uri()) @@ -1511,7 +1544,7 @@ pub(crate) async fn repo_archive( if params.refspec.as_str().is_empty() { return Err(XrpcError::invalid_request("missing ref parameter")); } - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let format = params.format; let format_name = format.name(); let archive_prefix = params.prefix.0.clone(); @@ -1655,9 +1688,11 @@ struct LanguagesOut { pub(crate) async fn repo_languages( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let repos = state.repos.clone(); let limit = state.byte_limits.response.get(); let languages_deadline = state.budgets.languages.get().deadline(); @@ -1725,11 +1760,13 @@ struct ListRefsOut { pub(crate) async fn git_list_refs( State(state): State>>, + headers: HeaderMap, + method: Method, ValidatedQuery(params): ValidatedQuery, ) -> Result { let offset = params.cursor; let limit = params.limit; - let grant = require_hosted(&state, params.repo)?; + let grant = require_readable(&state, &headers, &method, params.repo).await?; let repos = state.repos.clone(); let response_limit = state.byte_limits.response.get(); run_blocking(move || { diff --git a/knot2/crates/knot-xrpc/src/receive.rs b/knot2/crates/knot-xrpc/src/receive.rs index e1dca2c3a..7ed047cc8 100644 --- a/knot2/crates/knot-xrpc/src/receive.rs +++ b/knot2/crates/knot-xrpc/src/receive.rs @@ -10,7 +10,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::post; use futures::TryStreamExt; -use knot_acl::{PushGrant, ReadGrant}; +use knot_acl::{PushGrant, ReadGrant, Viewer}; use knot_messages::{ErrorKey, HttpMessages}; use knot_pack::{PackError, PackReceiver, ReceivedPack, SocketPeer}; use knot_receive::{Push, land}; @@ -87,7 +87,7 @@ async fn receive_named( headers: HeaderMap, body: Body, ) -> Response { - let grant = match crate::resolve_repo_named(&state, &did, &name).await { + let grant = match crate::resolve_repo_named(&state, &did, &name, &Viewer::anonymous()).await { Ok(grant) => grant, Err(error) => return error.into_response(), }; @@ -101,7 +101,7 @@ async fn receive_did( headers: HeaderMap, body: Body, ) -> Response { - let grant = match crate::resolve_repo_did(&state, &did) { + let grant = match crate::resolve_repo_did(&state, &did, &Viewer::anonymous()) { Ok(grant) => grant, Err(error) => return error.into_response(), }; diff --git a/knot2/crates/knot-xrpc/src/repos.rs b/knot2/crates/knot-xrpc/src/repos.rs index 98decd84b..9b8552ef4 100644 --- a/knot2/crates/knot-xrpc/src/repos.rs +++ b/knot2/crates/knot-xrpc/src/repos.rs @@ -7,7 +7,7 @@ use axum::response::{IntoResponse, Response}; use http::HeaderMap; use serde::{Deserialize, Serialize}; -use knot_acl::{KnotAcl, Lfs, Repos, can_admin_knot, can_delete_repo}; +use knot_acl::{KnotAcl, Lfs, ReadGrant, Repos, Viewer, can_admin_knot, can_delete_repo}; use knot_atproto::{PreparedRepoDid, RecordPresence}; use knot_cob::{CobHome, CobId, CobStore}; use knot_cobs::{ @@ -79,7 +79,10 @@ pub(crate) async fn reserve_key( method: crate::Method, body: Bytes, ) -> Result { - let actor = state.authenticate_write(&headers, &method).await?; + let actor = state + .authenticate_write(&headers, &method) + .await? + .into_did(); crate::authorize_create( &state, &actor, @@ -148,7 +151,9 @@ pub(crate) async fn create_repo( method: crate::Method, body: Bytes, ) -> Result { - let actor = state.authenticate_write(&headers, &method).await?; + let authed = state.authenticate_write(&headers, &method).await?; + let viewer = Viewer::from(authed.clone()); + let actor = authed.into_did(); crate::authorize_create( &state, &actor, @@ -186,7 +191,7 @@ pub(crate) async fn create_repo( if let Some(source) = &source && crate::imports::retry_failed( &state, - &crate::reads::require_hosted(&state, existing.clone())?, + &crate::reads::require_hosted(&state, existing.clone(), &viewer)?, &source.url, source.kind, ) @@ -221,7 +226,7 @@ pub(crate) async fn create_repo( let fork = match &source { Some(source) => Some(( - crate::forks::ForkSource::resolve(&state, &source.url).await?, + crate::forks::ForkSource::resolve(&state, &source.url, &viewer).await?, source.kind, )), None => None, @@ -425,11 +430,11 @@ pub(crate) async fn create_repo( }) .await?; crate::firehose::publish_sync(&state.firehose, &repo_did, &chunk.rev, &chunk.commit, now); - crate::social::open_manifest(&state, actor, repo_did.clone(), manifest, now).await + crate::social::open_manifest(&state, actor, &viewer, repo_did.clone(), manifest, now).await } .await; if queued_import { - state.imports.wake(repo_did.clone()); + state.imports.wake(created); } outcome?; @@ -467,10 +472,11 @@ async fn source_of( pub(crate) async fn populate( state: &Arc>, - repo_did: &RepoDid, + grant: &ReadGrant, fork: &crate::forks::ForkSource, kind: crate::forks::SourceKind, ) -> Result, XrpcError> { + let repo_did = grant.repo(); let tips = fork.refs.tips(); let scratch = state.repos.scratch_dir().to_path_buf(); std::fs::create_dir_all(&scratch).map_err(|error| XrpcError::internal(error.to_string()))?; @@ -492,18 +498,15 @@ pub(crate) async fn populate( let repos = state.repos.clone(); let index = Arc::clone(&state.index); let cob_locks = Arc::clone(&state.cob_locks); - let placed = repo_did.clone(); + let placed = grant.clone(); let origin = fork.origin.clone(); let refs = fork.refs.clone(); - let (grant, owner) = run_blocking(move || { - let _guard = cob_locks.repo(&placed); - let Resolved::Ready(Some(owner)) = index.owner_of(&placed) else { - return Err(XrpcError::conflict("repository was deleted during import")); - }; - let Resolved::Ready(Some(grant)) = knot_acl::read_grant(&index, placed) else { + let owner = run_blocking(move || { + let _guard = cob_locks.repo(placed.repo()); + let Resolved::Ready(Some(owner)) = index.owner_of(placed.repo()) else { return Err(XrpcError::conflict("repository was deleted during import")); }; - let repo = repos.open(&grant)?; + let repo = repos.open(&placed)?; let staging = knot_git::Staging::new(&repo) .map_err(|error| XrpcError::internal(error.to_string()))?; knot_pack::ingest_pack_file( @@ -524,11 +527,11 @@ pub(crate) async fn populate( .migrate_into(&repo) .map_err(|error| XrpcError::internal(error.to_string()))?; crate::forks::apply_source_refs(&repo, &refs, &origin, kind)?; - Ok((grant, AccountDid::from(owner))) + Ok(AccountDid::from(owner)) }) .await?; let acl = KnotAcl::new(&state.admins, state.admission, &state.index); - let pushing = knot_acl::push_grant(&acl, &state.index, &grant, &owner) + let pushing = knot_acl::push_grant(&acl, &state.index, grant, &owner) .ok_or_else(|| XrpcError::forbidden("the repository owner can't push to it"))?; let missing = crate::lfs::mirror_fork_objects( Arc::clone(state), @@ -662,7 +665,10 @@ pub(crate) async fn delete_repo( method: crate::Method, body: Bytes, ) -> Result { - let actor = state.authenticate_write(&headers, &method).await?; + let actor = state + .authenticate_write(&headers, &method) + .await? + .into_did(); let DeleteInput { repo: repo_did, force, diff --git a/knot2/crates/knot-xrpc/src/social.rs b/knot2/crates/knot-xrpc/src/social.rs index 834711dd6..65d2ac0e3 100644 --- a/knot2/crates/knot-xrpc/src/social.rs +++ b/knot2/crates/knot-xrpc/src/social.rs @@ -8,7 +8,7 @@ use axum::response::{IntoResponse, Response}; use http::HeaderMap; use jacquard_common::types::tid::Ticker; use jacquard_repo::storage::BlockStore; -use knot_acl::{ContributeGrant, KnotAcl, ReadGrant, RepoAccess}; +use knot_acl::{ContributeGrant, KnotAcl, ReadGrant, RepoAccess, Viewer}; use knot_cob::{ Approved, ChangePayload, CobError, CobHome, CobStore, Materialized, Record, WriteContext, }; @@ -47,6 +47,8 @@ use knot_types::{ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use knot_atproto::Authenticated; + use crate::error::XrpcError; use crate::query::ValidatedQuery; use crate::{XrpcState, admit_social, decode, project_collaborators, run_blocking}; @@ -1057,13 +1059,15 @@ pub(crate) async fn session( method: &crate::Method, repo: RepoDid, ) -> Result { - let actor = state + let authed = state .authenticate_write_for_repo(headers, method, &repo) .await?; + let viewer = Viewer::from(authed.clone()); + let actor = authed.into_did(); let permission = permission(state, &actor, &repo).await?; Ok(Session { actor, - grant: crate::reads::require_hosted(state, repo)?, + grant: crate::reads::require_hosted(state, repo, &viewer)?, permission, now: state.now(), }) @@ -1173,6 +1177,7 @@ pub(crate) async fn create_record( pub(crate) async fn open_manifest( state: &Arc>, actor: AccountDid, + viewer: &Viewer, repo: RepoDid, record: RecordBody, now: UnixSeconds, @@ -1180,7 +1185,7 @@ pub(crate) async fn open_manifest( let permission = permission(state, &actor, &repo).await?; let session = Session { actor, - grant: crate::reads::require_hosted(state, repo)?, + grant: crate::reads::require_hosted(state, repo, viewer)?, permission, now, }; @@ -1964,15 +1969,23 @@ async fn grant_open( collection.as_str() )) })?; - let actor = grant_actor(state, headers, method, &repo, &collection).await?; match collection.as_str() { knot_record::grant::MEMBER_COLLECTION => { + let actor = knot_grant_actor(state, headers, method, &repo, &collection).await?; crate::members::member_add(state, &actor, subject).await } knot_record::grant::BLOCK_COLLECTION => { + let actor = knot_grant_actor(state, headers, method, &repo, &collection).await?; crate::blocklist::block_add(state, &actor, subject).await } - _ => crate::collaborators::collaborator_add(state, &actor, &repo, subject).await, + _ => { + let authed = state + .authenticate_write_for_repo(headers, method, &repo) + .await?; + let viewer = Viewer::from(authed.clone()); + crate::collaborators::collaborator_add(state, authed.did(), &viewer, &repo, subject) + .await + } }?; Ok(crate::ok_empty()) } @@ -1994,42 +2007,49 @@ async fn grant_erase( return Err(no_swap_commit()); } let subject = grant_subject(&collection, Some(rkey))?; - let actor = grant_actor(state, headers, method, &repo, &collection).await?; match collection.as_str() { knot_record::grant::MEMBER_COLLECTION => { + let actor = knot_grant_actor(state, headers, method, &repo, &collection).await?; crate::members::member_remove(state, &actor, subject).await } knot_record::grant::BLOCK_COLLECTION => { + let actor = knot_grant_actor(state, headers, method, &repo, &collection).await?; crate::blocklist::block_remove(state, &actor, subject).await } - _ => crate::collaborators::collaborator_remove(state, &actor, &repo, subject).await, + _ => { + let authed = state + .authenticate_write_for_repo(headers, method, &repo) + .await?; + let viewer = Viewer::from(authed.clone()); + crate::collaborators::collaborator_remove(state, authed.did(), &viewer, &repo, subject) + .await + } }?; Ok(crate::ok_empty()) } -async fn grant_actor( +// the legacy shared secret names the service owner with no token. that can't make a viewer +// so it only reaches the knot's own member and block grants +async fn knot_grant_actor( state: &Arc>, headers: &HeaderMap, method: &crate::Method, repo: &RepoDid, collection: &RecordCollection, ) -> Result { - if collection.as_str() == knot_record::grant::COLLABORATOR_COLLECTION { - state - .authenticate_write_for_repo(headers, method, repo) - .await - } else { - on_this_knot(repo, &state.knot_repo, "record")?; - if let Some(actor) = state.legacy_admin_actor(headers)? { - tracing::warn!( - collection = collection.as_str(), - actor = actor.as_str(), - "legacy shared-secret admin path is still in use" - ); - return Ok(actor); - } - state.authenticate_write(headers, method).await - } + on_this_knot(repo, &state.knot_repo, "record")?; + if let Some(actor) = state.legacy_admin_actor(headers)? { + tracing::warn!( + collection = collection.as_str(), + actor = actor.as_str(), + "legacy shared-secret admin path is still in use" + ); + return Ok(actor); + } + state + .authenticate_write(headers, method) + .await + .map(Authenticated::into_did) } const NOT_LABEL_AUTHORITY: &str = "label defs belong to the repository's owner alone"; @@ -2708,6 +2728,8 @@ struct Versions { pub(crate) async fn list_record_versions( State(state): State>>, + headers: HeaderMap, + method: crate::Method, ValidatedQuery(query): ValidatedQuery, ) -> Result { let Target { repo, address } = any_target(&query.uri)?; @@ -2717,8 +2739,12 @@ pub(crate) async fn list_record_versions( "{collection} isn't a collection this knot keeps version chains for" ))); } - let grant = ready(knot_acl::read_grant(&state.index, repo.clone()), "registry")? - .ok_or_else(|| XrpcError::not_found("No such repository on this knot"))?; + let viewer = state.reader(&headers, &method, &repo).await?; + let grant = ready( + knot_acl::read_grant(&state.index, repo.clone(), &viewer), + "registry", + )? + .ok_or_else(|| XrpcError::not_found("No such repository on this knot"))?; warm_social(&state, &grant).await?; let object = object_at(&state.social.of(&grant), &address)?; let index_state = Arc::clone(&state); diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index 5dc75f0f0..06dd3444a 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -196,6 +196,8 @@ async fn repo_status(world: &World, did: &RepoDid) -> serde_json::Value { json_of( crate::atproto::sync_get_repo_status( State(Arc::clone(&world.state)), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.sync.getRepoStatus"), axum::extract::Query(crate::atproto::DidParams { did: crate::atproto::RepoSubject::Did(did.clone()), }), @@ -206,6 +208,20 @@ async fn repo_status(world: &World, did: &RepoDid) -> serde_json::Value { .await } +async fn repo_status_as(world: &World, did: &RepoDid, headers: HeaderMap) -> Response { + into_response( + crate::atproto::sync_get_repo_status( + world.state(), + headers, + crate::Method::from_nsid("com.atproto.sync.getRepoStatus"), + axum::extract::Query(crate::atproto::DidParams { + did: crate::atproto::RepoSubject::Did(did.clone()), + }), + ) + .await, + ) +} + async fn call( world: &World, handler: F, @@ -1619,6 +1635,8 @@ async fn record_at( let response = into_response( crate::atproto::repo_get_record( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.repo.getRecord"), crate::query::ValidatedQuery(record_of(repo, uri, cid.cloned())), ) .await, @@ -1631,9 +1649,14 @@ async fn records_in(world: &World, repo: &RepoDid, collection: &RecordCollection let params: crate::atproto::ListRecordsParams = serde_json::from_value(json!({ "repo": repo, "collection": collection })).unwrap(); let listed = json_of( - crate::atproto::repo_list_records(world.state(), crate::query::ValidatedQuery(params)) - .await - .unwrap(), + crate::atproto::repo_list_records( + world.state(), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.repo.listRecords"), + crate::query::ValidatedQuery(params), + ) + .await + .unwrap(), ) .await; listed["records"] @@ -1647,6 +1670,8 @@ async fn records_in(world: &World, repo: &RepoDid, collection: &RecordCollection async fn block_served(world: &World, repo: &RepoDid, cid: &crate::out::CidString) -> bool { let response = crate::atproto::sync_get_blocks( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.sync.getBlocks"), crate::atproto::BlocksQuery { did: crate::atproto::RepoSubject::Did(repo.clone()), cids: vec![cid.cid()], @@ -1689,6 +1714,8 @@ async fn versions_ordered( json_of(into_response( crate::social::list_record_versions( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("sh.tangled.repo.listCobVersions"), crate::query::ValidatedQuery(crate::social::VersionsQuery { uri: uri.clone(), limit, @@ -2221,6 +2248,8 @@ async fn sync_record_blocks( ) -> std::collections::BTreeMap { let response = crate::atproto::sync_get_record( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.sync.getRecord"), crate::query::ValidatedQuery(crate::atproto::SyncRecordParams { did: crate::atproto::RepoSubject::Did(repo.clone()), collection: knot_types::RecordCollection::new( @@ -2248,6 +2277,8 @@ async fn repo_export_blocks( }); let response = crate::atproto::sync_get_repo( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.sync.getRepo"), crate::query::ValidatedQuery(crate::atproto::RepoExportParams { did: crate::atproto::RepoSubject::Did(repo.clone()), since, @@ -2391,6 +2422,8 @@ async fn deleted_prose_stays_off_every_presenting_route_and_reachable_from_histo ); let blocks = crate::atproto::sync_get_blocks( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("com.atproto.sync.getBlocks"), crate::atproto::BlocksQuery { did: crate::atproto::RepoSubject::Did(repo.clone()), cids: vec![v1.cid()], @@ -3498,11 +3531,23 @@ async fn resolve_by_name_matches_the_rkey_case_sensitively() { ); assert!( - crate::merge::resolve_by_name(&*state, &owner, &RepoName::new("anemone").unwrap()).is_ok(), + crate::merge::resolve_by_name( + &*state, + &owner, + &RepoName::new("anemone").unwrap(), + &knot_acl::Viewer::anonymous(), + ) + .is_ok(), "the exact rkey resolves" ); assert!( - crate::merge::resolve_by_name(&*state, &owner, &RepoName::new("Anemone").unwrap()).is_err(), + crate::merge::resolve_by_name( + &*state, + &owner, + &RepoName::new("Anemone").unwrap(), + &knot_acl::Viewer::anonymous(), + ) + .is_err(), "rkeys are case-sensitive, so a differently-cased name mustn't resolve at all" ); } @@ -4471,6 +4516,8 @@ mod merge_endpoints { let clean = json_of( crate::merge::merge_check( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("org.tangled.git.mergeCheck"), input(&BranchName::new("main").unwrap(), &patch), ) .await @@ -4484,6 +4531,8 @@ mod merge_endpoints { let conflicted = json_of( crate::merge::merge_check( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("org.tangled.git.mergeCheck"), input(&BranchName::new("side").unwrap(), &patch), ) .await @@ -4500,6 +4549,8 @@ mod merge_endpoints { let missing = crate::merge::merge_check( world.state(), + HeaderMap::new(), + crate::Method::from_nsid("org.tangled.git.mergeCheck"), input( &BranchName::new("main").unwrap(), &RecordRkey::new("3jzfcijpj2z2a").unwrap(), @@ -4564,8 +4615,15 @@ mod merge_endpoints { ), ]; for (input, want, why) in rejections { - let response = - into_response(crate::merge::merge_check(world.state(), body(input)).await); + let response = into_response( + crate::merge::merge_check( + world.state(), + HeaderMap::new(), + crate::Method::from_nsid("org.tangled.git.mergeCheck"), + body(input), + ) + .await, + ); assert_eq!(response.status(), want, "{why}"); } @@ -4715,7 +4773,7 @@ mod fork_endpoints { async fn fork_repo(world: &World, source: &Did, pds: &KnotServiceUrl, name: &str) -> RepoDid { world.publish_plc_doc(source, pds); let did = create_repo_from(world, tangled(source), name).await; - crate::imports::run_one(&world.state, &did).await; + crate::imports::run_one(&world.state, &knot_acl::ReadGrant::internal(did.clone())).await; did } @@ -4801,7 +4859,7 @@ mod fork_endpoints { ); assert_eq!(described_content(&world, &did).as_deref(), Some("fetching")); - crate::imports::run_one(&world.state, &did).await; + crate::imports::run_one(&world.state, &knot_acl::ReadGrant::internal(did.clone())).await; assert_eq!( world .layout @@ -4827,7 +4885,7 @@ mod fork_endpoints { job["refs"]["head_symref"] = serde_json::Value::Null; }); - crate::imports::run_one(&world.state, &did).await; + crate::imports::run_one(&world.state, &knot_acl::ReadGrant::internal(did.clone())).await; let imported = world.layout.open(&did).unwrap(); assert_eq!(imported.head().unwrap().name, master_ref()); assert_eq!(imported.find_ref(&master_ref()).unwrap(), Some(tip)); @@ -4870,7 +4928,7 @@ mod fork_endpoints { crate::imports::ImportState::Queued ); - crate::imports::run_one(&world.state, &did).await; + crate::imports::run_one(&world.state, &knot_acl::ReadGrant::internal(did.clone())).await; assert_eq!( world .layout @@ -4910,7 +4968,7 @@ mod fork_endpoints { crate::imports::ImportState::Queued ); - crate::imports::run_one(&world.state, &did).await; + crate::imports::run_one(&world.state, &knot_acl::ReadGrant::internal(did.clone())).await; assert_eq!( world .layout @@ -4977,7 +5035,7 @@ mod fork_endpoints { job["refs"] = serde_json::Value::Null; }); - crate::imports::run_one(&world.state, &did).await; + crate::imports::run_one(&world.state, &knot_acl::ReadGrant::internal(did.clone())).await; let after = read_job(&world, &did); assert_eq!(after.state, crate::imports::ImportState::Failed); assert!( @@ -5030,7 +5088,11 @@ mod fork_endpoints { let tip = tips[1]; let imported_did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; - crate::imports::run_one(&world.state, &imported_did).await; + crate::imports::run_one( + &world.state, + &knot_acl::ReadGrant::internal(imported_did.clone()), + ) + .await; let imported = world.layout.open(&imported_did).unwrap(); assert_eq!( imported.find_ref(&main_ref()).unwrap(), @@ -8045,3 +8107,31 @@ mod pulls { ); } } + +#[tokio::test] +async fn a_read_takes_a_good_token_or_none_and_refuses_a_bad_one() { + let world = World::new(); + world.grandfather(MEMBER_HOST); + let repo = create_repo_helper(&world, "anemone").await; + let method = nsid("com.atproto.sync.getRepoStatus"); + + let anonymous = repo_status_as(&world, &repo, HeaderMap::new()).await; + assert_eq!(anonymous.status(), StatusCode::OK); + + let token = mint(&world.stranger, &method); + let credentialed = repo_status_as(&world, &repo, bearer(&token)).await; + assert_eq!(credentialed.status(), StatusCode::OK); + let again = repo_status_as(&world, &repo, bearer(&token)).await; + assert_eq!( + again.status(), + StatusCode::OK, + "a read token isn't single use" + ); + + let garbage = repo_status_as(&world, &repo, bearer("not.a.jwt")).await; + assert_eq!(garbage.status(), StatusCode::UNAUTHORIZED); + + let wrong_method = mint(&world.stranger, &nsid("com.atproto.sync.getLatestCommit")); + let misbound = repo_status_as(&world, &repo, bearer(&wrong_method)).await; + assert_eq!(misbound.status(), StatusCode::UNAUTHORIZED); +} diff --git a/knot2/crates/knot-xrpc/tests/common/mod.rs b/knot2/crates/knot-xrpc/tests/common/mod.rs index 2c50d6b7e..79d13ffff 100644 --- a/knot2/crates/knot-xrpc/tests/common/mod.rs +++ b/knot2/crates/knot-xrpc/tests/common/mod.rs @@ -98,12 +98,16 @@ pub async fn serve_bob_pds( pub async fn serve_pushes(world: &World) -> std::net::SocketAddr { let resolver: Arc = { let index = Arc::clone(&world.state.index); - Arc::new(move |target: &knot_pack::RepoTarget| match target { - knot_pack::RepoTarget::Did(did) => knot_acl::read_grant(&index, did.clone()).into(), - knot_pack::RepoTarget::OwnerPath(owner, path) => { - knot_acl::read_grant_by_path(&index, owner, path).into() - } - }) + Arc::new( + move |target: &knot_pack::RepoTarget, viewer: &knot_acl::Viewer| match target { + knot_pack::RepoTarget::Did(did) => { + knot_acl::read_grant(&index, did.clone(), viewer).into() + } + knot_pack::RepoTarget::OwnerPath(owner, path) => { + knot_acl::read_grant_by_path(&index, owner, path, viewer).into() + } + }, + ) }; let (_, advertisement) = knot_pack::edge_routes(knot_pack::EdgeConfig { receive: Some(knot_xrpc::receive_advertiser(Arc::clone(&world.state))), diff --git a/knot2/justfile b/knot2/justfile index b48e3c4ef..5bc9ca0bf 100644 --- a/knot2/justfile +++ b/knot2/justfile @@ -42,7 +42,7 @@ t55xx *tests: ci: fmt-check clippy test checks instrument-tests fuzz-ci -checks: require-tools check-no-subprocess (check-no-banned-deps "no-sql" "an embedded database" "rusqlite|libsqlite3-sys|sqlx|sqlx-core|sled|fjall|redb") (check-no-banned-deps "no-native-git" "a native git or TLS shim" "git2|libgit2-sys|openssl-sys|zlib-ng|zlib-ng-sys") check-no-string-ids check-no-unguarded-receive check-no-raw-repo-open check-fuzz-targets-enumerated check-workflow-toolchain +checks: require-tools check-no-subprocess (check-no-banned-deps "no-sql" "an embedded database" "rusqlite|libsqlite3-sys|sqlx|sqlx-core|sled|fjall|redb") (check-no-banned-deps "no-native-git" "a native git or TLS shim" "git2|libgit2-sys|openssl-sys|zlib-ng|zlib-ng-sys") check-no-string-ids check-no-unguarded-receive check-no-raw-repo-open check-no-forged-viewer check-fuzz-targets-enumerated check-workflow-toolchain require-tools: #!/usr/bin/env bash @@ -120,6 +120,17 @@ check-no-raw-repo-open: fi echo "ok: serving code opens repos, lfs stores and social reads only through knot_acl" +check-no-forged-viewer: + #!/usr/bin/env bash + set -euo pipefail + hits=$(grep -rn --include="*.rs" "Viewer::ssh_identified" crates/*/src | grep -v '^crates/knot-ssh/src/exec.rs:' | grep -v '^crates/knot-acl/src/viewer.rs:' || [ $? = 1 ]) + if [ -n "$hits" ]; then + echo "no-forged-viewer check failed: only knot-ssh's exec names a viewer from a bare account, everything else needs an auth proof" >&2 + echo "$hits" >&2 + exit 1 + fi + echo "ok: no viewer is built from a bare account outside knot-ssh" + check-fuzz-targets-enumerated: #!/usr/bin/env bash set -euo pipefail -- 2.54.0