chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A space a session's grant leaves out is not asked again for an hour
crates/knotless-serve/src/spaces.rs+57−3
| @@ -36,9 +36,9 @@ use knotless::url::RepoUrl; | ||
use knotless::xrpc::{Xrpc, XrpcError}; | ||
use serde::Deserialize; | ||
use serde_json::{Value, json}; | ||
use std::collections::{BTreeSet, HashSet}; | ||
use std::sync::Arc; | ||
use std::collections::{BTreeSet, HashMap, HashSet}; | ||
use std::sync::atomic::Ordering; | ||
use std::sync::{Arc, LazyLock, Mutex}; | ||
use std::time::{Duration, Instant}; | ||
| ||
/// The collections a private repository's pages read, as the appview | ||
| @@ -288,8 +288,10 @@ async fn owned(app: &App, index: &Index, did: &str) -> Result<()> { | ||
// Asked afresh, as a reading is, since a kept credential outlives a | ||
// removal. | ||
space::forget(&ctx); | ||
for uri in index.maybe_member(did)? { | ||
let members = index.maybe_member(did)?.into_iter(); | ||
for uri in members.filter(|uri| !out_of_scope(did, uri)) { | ||
let joined = space::joined(&ctx, &uri).await; | ||
refused_for_scope(did, &uri, &joined); | ||
// Anything but an answer leaves it as it was, to ask again. | ||
if joined.is_ok() { | ||
index.read_by(&uri, did)?; | ||
| @@ -310,6 +312,37 @@ async fn owned(app: &App, index: &Index, did: &str) -> Result<()> { | ||
Ok(()) | ||
} | ||
| ||
/// The spaces each account's session was refused for want of scope, by | ||
/// account and space, and when: not asked again until [`SCOPE_ASKED`] | ||
/// passes, as its grant stays as it was until it logs in again, and each | ||
/// asking costs a call to its PDS every [`DISCOVER`]. | ||
static OUT_OF_SCOPE: LazyLock<Mutex<HashMap<(String, String), Instant>>> = | ||
LazyLock::new(Mutex::default); | ||
| ||
/// How long a space a session was refused for want of scope is not asked. | ||
const SCOPE_ASKED: Duration = Duration::from_secs(60 * 60); | ||
| ||
/// Whether `did`'s session was refused the space `uri` for want of scope | ||
/// within [`SCOPE_ASKED`]. | ||
fn out_of_scope(did: &str, uri: &str) -> bool { | ||
let refused = OUT_OF_SCOPE.lock().unwrap(); | ||
let at = refused.get(&(did.to_string(), uri.to_string())); | ||
at.is_some_and(|at| at.elapsed() < SCOPE_ASKED) | ||
} | ||
| ||
/// Keeps that `did`'s session was refused the space `uri` for want of | ||
/// scope, if `joined` says it was. | ||
fn refused_for_scope<T>(did: &str, uri: &str, joined: &Result<T>) { | ||
if joined | ||
.as_ref() | ||
.err() | ||
.is_some_and(knotless::oauth::out_of_scope) | ||
{ | ||
let key = (did.to_string(), uri.to_string()); | ||
OUT_OF_SCOPE.lock().unwrap().insert(key, Instant::now()); | ||
} | ||
} | ||
| ||
/// Takes in what was written to the space `uri` since it was last synced, | ||
/// and says whether anything was. | ||
pub async fn sync(ctx: &Ctx, index: &Index, uri: &str) -> Result<bool> { | ||
| @@ -424,3 +457,24 @@ async fn whole(index: &Index, uri: &str, theirs: &Remote, kem: &PublicKey) -> Re | ||
index.set_space_writer(uri, did, &whole.commit.rev, &whole.hash.state())?; | ||
Ok(()) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn a_space_out_of_scope_is_not_asked_again_a_while() { | ||
let (did, uri) = ("did:plc:s", "at://did:plc:o/space/dev.knotless.org/self"); | ||
refused_for_scope::<()>(did, uri, &Ok(())); | ||
refused_for_scope::<()>(did, uri, &Err(anyhow::anyhow!("connection refused"))); | ||
assert!(!out_of_scope(did, uri)); | ||
let scope = anyhow::anyhow!("InvalidToken (403): Scope does not permit reading {uri}"); | ||
refused_for_scope::<()>(did, uri, &Err(scope)); | ||
assert!(out_of_scope(did, uri)); | ||
assert!(!out_of_scope("did:plc:t", uri)); | ||
let key = (did.to_string(), uri.to_string()); | ||
let long_ago = Instant::now() - SCOPE_ASKED; | ||
OUT_OF_SCOPE.lock().unwrap().insert(key, long_ago); | ||
assert!(!out_of_scope(did, uri)); | ||
} | ||
} | ||
crates/knotless-serve/src/telemetry.rs+7−2
| @@ -319,8 +319,9 @@ pub fn space_unheard<T>(uri: &str, asked: anyhow::Result<T>) { | ||
| ||
/// Logs why `did` was not proven to read the space `uri`, if they were | ||
/// not: at info when its authority refused them, which takes it out of | ||
/// what they are listed as reading; at warn when it could not be asked, | ||
/// which leaves that as it was, to ask again. | ||
/// what they are listed as reading, or when their session's grant leaves | ||
/// the space out, which only logging in again changes; at warn when it | ||
/// could not be asked, which leaves that as it was, to ask again. | ||
pub fn reader_checked<T>(uri: &str, did: &str, joined: &anyhow::Result<T>) { | ||
if let Err(err) = joined { | ||
let fields = Fields { | ||
| @@ -331,6 +332,8 @@ pub fn reader_checked<T>(uri: &str, did: &str, joined: &anyhow::Result<T>) { | ||
}; | ||
if knotless::space::denied(err) { | ||
telemetry::note(Level::INFO, "space reader refused", &fields); | ||
} else if knotless::oauth::out_of_scope(err) { | ||
telemetry::note(Level::INFO, "space reader out of scope", &fields); | ||
} else { | ||
telemetry::note(Level::WARN, "space reader not checked", &fields); | ||
} | ||
| @@ -438,6 +441,8 @@ mod tests { | ||
"did:plc:m", | ||
&Err(anyhow::anyhow!("connection refused")), | ||
); | ||
let scope = anyhow::anyhow!("InvalidToken (403): Scope does not permit reading {uri}"); | ||
reader_checked::<()>(uri, "did:plc:m", &Err(scope)); | ||
} | ||
| ||
#[test] | ||
docs/design/access.md+4−1
| @@ -323,7 +323,10 @@ repository on the pages is a repository in that index. | ||
open it, and no longer once its authority refuses them; asking it, as | ||
them, can fail without an answer (the authority unreached, or their | ||
login not let ask), which leaves it listed, to ask again, and logs a | ||
warning, `space reader not checked`. One shared only to | ||
warning, `space reader not checked`. A login whose grant leaves the | ||
space out logs `space reader out of scope`, at info, and that space is | ||
not asked as them again for an hour, as their grant stays as it is | ||
until they log in again. One shared only to | ||
read names them only in `readers`, which the server takes for | ||
notifications alone, so is listed to them once they have read it here. Which they | ||
were is kept when they log out, listed to no one meanwhile, and asked | ||