From 4186b504eae5b19a4bc8d934a2c26567f4635b47 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:05:29 -0700 Subject: [PATCH 01/11] quest(auth/narrowing): claim Co-Authored-By: Claude Opus 5.5 From 7e6dd2f451b7539618f2888e10d4988ba83b612c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:45:31 -0700 Subject: [PATCH 02/11] feat(net): narrow a live session's grant in place Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 245 +++++++++++++++++++++++++----- rs/moq-net/src/client.rs | 11 +- rs/moq-net/src/ietf/auth.rs | 15 +- rs/moq-net/src/ietf/publisher.rs | 26 ++-- rs/moq-net/src/ietf/session.rs | 7 +- rs/moq-net/src/ietf/subscriber.rs | 41 ++++- rs/moq-net/src/lite/publisher.rs | 135 +++++++++------- rs/moq-net/src/lite/subscriber.rs | 177 ++++++++++++++++++--- rs/moq-net/tests/auth.rs | 232 ++++++++++++++++++++++++++++ 9 files changed, 752 insertions(+), 137 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 7ea4acf06a..c12b0f3a6a 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -10,7 +10,9 @@ //! The other direction, answering the peer's tokens, is automatic: the session //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's -//! driver, and then answers every token the peer presents. +//! driver, and then answers every token the peer presents. Either way, +//! [`narrow`](Handle::narrow) takes access away from the peer on a live session, +//! on every version, whether or not it speaks AUTH. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -62,22 +64,37 @@ impl Grant { } } - fn patterns(&self, direction: Direction) -> &Patterns { - match direction { - Direction::Publish => &self.publish, - Direction::Subscribe => &self.subscribe, - } - } - /// Fold `other` into this grant: the paths either allows, lapsing at the /// earlier expiry, which is when the union next shrinks. fn union(&mut self, other: &Self) { self.publish.extend(other.publish.iter().cloned()); self.subscribe.extend(other.subscribe.iter().cloned()); - self.expires = match (self.expires, other.expires) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), + self.expires = earliest(self.expires, other.expires); + } + + /// The paths both grants allow, lapsing at the earlier expiry. + /// + /// Fails closed: an intersection too large to hold grants nothing, never more. + fn intersect(&self, other: &Self) -> Self { + let both = |a: &Patterns, b: &Patterns| { + a.intersect(b).unwrap_or_else(|err| { + tracing::warn!(%err, "grant intersection too large; granting nothing"); + Patterns::new() + }) }; + Self { + publish: both(&self.publish, &other.publish), + subscribe: both(&self.subscribe, &other.subscribe), + expires: earliest(self.expires, other.expires), + } + } +} + +/// The earlier of two optional deadlines, where `None` is never. +fn earliest(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), } } @@ -97,8 +114,11 @@ pub(crate) struct State { union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// Bumped whenever the union changes, so the session's per-stream gates can - /// skip re-matching paths on every wakeup. + /// The most this side lets the peer do, in the peer's terms: `None` until + /// [`Handle::narrow`], then only ever narrower. + ceiling: Option, + /// Bumped whenever the union or the ceiling changes, so the session's per-stream + /// gates can skip re-matching paths on every wakeup. epoch: u64, acceptor: Acceptor, } @@ -150,6 +170,71 @@ impl State { fn closed(&self) -> Option { self.closed.clone() } + + /// The union's and the ceiling's patterns for `direction`, each `None` while + /// unrestricted. + fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) { + // The peer's grant names what we may do; the ceiling names what the peer may, + // so what we send is what the peer may subscribe to, and the other way around. + match direction { + Direction::Publish => ( + self.union.as_ref().map(|union| &union.publish), + self.ceiling.as_ref().map(|ceiling| &ceiling.subscribe), + ), + Direction::Subscribe => ( + self.union.as_ref().map(|union| &union.subscribe), + self.ceiling.as_ref().map(|ceiling| &ceiling.publish), + ), + } + } + + /// What `direction` allows right now. + fn permit(&self, direction: Direction) -> Permit { + let (granted, ceiling) = self.parts(direction); + Permit { + granted: granted.cloned(), + ceiling: ceiling.cloned(), + } + } + + /// Whether `direction` allows `path` right now, without copying the patterns. + fn allows(&self, direction: Direction, path: &str) -> bool { + let (granted, ceiling) = self.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) && ceiling.is_none_or(|ceiling| ceiling.matches(path)) + } +} + +/// What one direction of a session allows: the grant the peer gave this side (the +/// union of its tokens) and the ceiling this side narrowed the peer to, each `None` +/// while unrestricted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Permit { + granted: Option, + ceiling: Option, +} + +impl Permit { + /// Whether both allow `path`. + pub(crate) fn matches(&self, path: &str) -> bool { + self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_ceiling(path) + } + + /// Whether the ceiling alone allows `path`: for what the peer offers us, which our + /// own grant does not decide. + pub(crate) fn within_ceiling(&self, path: &str) -> bool { + self.ceiling.as_ref().is_none_or(|ceiling| ceiling.matches(path)) + } + + /// Whether this allows everything `other` does, judged part by part, so a `false` + /// may be spurious but a `true` never is. + pub(crate) fn covers(&self, other: &Self) -> bool { + let covers = |this: &Option, other: &Option| match (this, other) { + (None, _) => true, + (Some(_), None) => false, + (Some(this), Some(other)) => this.covers(other), + }; + covers(&self.granted, &other.granted) && covers(&self.ceiling, &other.ceiling) + } } /// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). @@ -354,26 +439,72 @@ impl Handle { Poll::Ready(()) } + /// Narrow what the peer may do on this session to at most `grant`, in the + /// session's own paths. + /// + /// A narrowing only ever takes away: the session keeps what both its current + /// ceiling and `grant` allow, so a wider grant changes nothing. What falls outside + /// ends at once, with [`Error::Unauthorized`] where it has a reader: announcements + /// to the peer retract, its new requests are refused, its subscriptions reset, and + /// the broadcasts it published abort. The rest of the session carries on. It works + /// on every version, since the session enforces it without the peer's help. + /// + /// When the session answers the peer's connection credential itself, the peer is + /// told the narrowed grant, `expires` included. An application answering tokens + /// through [`requests`](Self::requests) updates its own [`Issued`] grants. + pub fn narrow(&self, grant: &Grant) { + let mut state = self.state.lock(); + let ceiling = match &state.ceiling { + Some(ceiling) => ceiling.intersect(grant), + None => grant.clone(), + }; + if state.ceiling.as_ref() != Some(&ceiling) { + state.ceiling = Some(ceiling); + state.epoch += 1; + } + } + /// The union once it changed since `epoch` last saw it, advancing `epoch`. It /// only ever changes into `Some`: no answer yet is the initial state. pub(crate) fn poll_union(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { - let seen = *epoch; - let state = ready_or!(self.state.poll(waiter, |state| match state.epoch != seen { - true => Poll::Ready(()), - false => Poll::Pending, - })); + let state = ready_or!(self.poll_epoch(*epoch, waiter)); *epoch = state.epoch; Poll::Ready(state.union.clone()) } - /// Whether the union allows `path` right now. A union that is still `None` (no - /// answer yet, or a version without AUTH) allows everything. + /// What `direction` allows once it may have changed since `epoch` last saw it, + /// advancing `epoch`. + pub(crate) fn poll_permit(&self, direction: Direction, epoch: &mut u64, waiter: &kio::Waiter) -> Poll { + let state = ready_or!(self.poll_epoch(*epoch, waiter)); + *epoch = state.epoch; + Poll::Ready(state.permit(direction)) + } + + /// The ceiling once it may have changed since `epoch` last saw it, advancing `epoch`. + pub(crate) fn poll_ceiling(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { + let state = ready_or!(self.poll_epoch(*epoch, waiter)); + *epoch = state.epoch; + Poll::Ready(state.ceiling.clone()) + } + + fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { + self.state.poll(waiter, |state| match state.epoch != seen { + true => Poll::Ready(()), + false => Poll::Pending, + }) + } + + /// Whether `direction` allows `path` right now: the union (allowing everything + /// while it is `None`, before an answer or on a version without AUTH) and the + /// ceiling both. pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - state - .union - .as_ref() - .is_none_or(|union| union.patterns(direction).matches(path)) + self.state.read().allows(direction, path) + } + + /// Whether the ceiling alone allows `direction` at `path`: for a route the peer + /// offers us, which our own grant does not decide. + pub(crate) fn within_ceiling(&self, direction: Direction, path: &str) -> bool { + self.state.read().parts(direction).1.is_none_or(|ceiling| ceiling.matches(path)) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and @@ -707,18 +838,21 @@ impl Drop for Issued { } } -/// Which half of a grant a [`Gate`] checks. +/// Which way media flows, from this side's view. #[derive(Clone, Copy)] pub(crate) enum Direction { + /// This side sends to the peer: our grant's `publish`, and the ceiling's `subscribe`. Publish, + /// The peer sends to this side: our grant's `subscribe`, and the ceiling's `publish`. Subscribe, } -/// Watches whether the union still allows one path, for a long-lived stream that -/// must end once it does not. +/// Watches whether the session still allows one path, for a request that must end +/// once it does not. /// -/// Cheap to poll on every wakeup: the path is only re-matched when the union -/// changes. +/// Create it at the request's first check and hold it to the end, so a change +/// landing in between is never missed. Cheap to poll on every wakeup: the path is +/// only re-matched when the union or the ceiling changes. pub(crate) struct Gate { handle: Handle, path: crate::PathOwned, @@ -736,20 +870,59 @@ impl Gate { } } - /// Resolve once the union no longer allows the path. A union that is still - /// `None` (no answer yet, or a version without AUTH) allows everything. + /// Resolve once the session no longer allows the path. A union that is still + /// `None` (no answer yet, or a version without AUTH) and a session never narrowed + /// allow everything. pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { loop { - let Some(union) = ready_or!(self.handle.poll_union(&mut self.epoch, waiter)) else { - continue; - }; - if !union.patterns(self.direction).matches(self.path.as_str()) { + let permit = ready_or!(self.handle.poll_permit(self.direction, &mut self.epoch, waiter)); + if !permit.matches(self.path.as_str()) { return Poll::Ready(()); } } } } +/// The default acceptor's answer to the peer's connection credential: the grant +/// the session's origin handles allow, narrowed by the ceiling as it changes. +pub(crate) struct DefaultGrant { + handle: Handle, + base: Grant, + epoch: u64, + ceiling: Option, + /// The grant last handed out, so an unrelated wakeup sends nothing. + sent: Option, +} + +impl DefaultGrant { + pub(crate) fn new(handle: Handle, base: Grant) -> Self { + let ceiling = handle.state.read().ceiling.clone(); + Self { + handle, + base, + epoch: 0, + ceiling, + sent: None, + } + } + + /// The grant to send next: the first, then each one a narrowing changes. + pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll { + while let Poll::Ready(ceiling) = self.handle.poll_ceiling(&mut self.epoch, waiter) { + self.ceiling = ceiling; + } + let grant = match &self.ceiling { + Some(ceiling) => self.base.intersect(ceiling), + None => self.base.clone(), + }; + if self.sent.as_ref() == Some(&grant) { + return Poll::Pending; + } + self.sent = Some(grant.clone()); + Poll::Ready(grant) + } +} + /// The close reason naming a broadcast published outside the grant. [`Error`] carries /// no payload, so the path travels here, in the session's own terms. pub(crate) fn unauthorized_reason(path: &crate::Path) -> String { diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index b28df340f4..663e3f34b3 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -408,6 +408,8 @@ impl Client { }; let stream = stream.with_version(v); + // Draft 14-16 carry no AUTH, but the session still narrows through this handle. + let auth = crate::auth::Handle::new(false); // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), @@ -423,14 +425,9 @@ impl Client { path: None, peer_setup_stream: None, peer_declared: Some(peer_declared), - auth: crate::auth::Handle::new(false), + auth: auth.clone(), })?; - ( - None, - crate::driver::Protocol::Ietf(protocol), - goaway, - crate::auth::Handle::new(false), - ) + (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } }; diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 76214462b5..0e29661fd8 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -395,6 +395,7 @@ impl Serve { version: Version, ) { let issue = Issue::shared(); + let mut default = None; match self.handle.acceptor() { Some(requests) => { // A closed queue hands the request back, and dropping it refuses the token. @@ -412,10 +413,11 @@ impl Serve { } return; } - None => issue.lock().outbox.push_back(Reply::Grant(self.peer_grant)), + // Re-sent whenever a narrowing changes it. + None => default = Some(crate::auth::DefaultGrant::new(self.handle.clone(), self.peer_grant)), } - let err = match serve_issue(&self.runtime, &issue, &mut stream, version).await { + let err = match serve_issue(&self.runtime, &issue, default.as_mut(), &mut stream, version).await { Ok(()) => Error::Cancel, Err(err) => { match &err { @@ -431,10 +433,12 @@ impl Serve { } } -/// Write the acceptor's replies until either side ends the token. +/// Write the acceptor's replies until either side ends the token. `default` feeds +/// the default acceptor's grant into the outbox as it changes. async fn serve_issue( runtime: &crate::time::Clock, issue: &kio::Shared, + mut default: Option<&mut crate::auth::DefaultGrant>, stream: &mut Stream, version: Version, ) -> Result<(), Error> { @@ -449,6 +453,11 @@ async fn serve_issue( if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { return Poll::Ready(Next::Withdrawn(res)); } + if let Some(default) = default.as_deref_mut() + && let Poll::Ready(grant) = default.poll(waiter) + { + issue.lock().outbox.push_back(Reply::Grant(grant)); + } match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { true => Poll::Pending, false => Poll::Ready(()), diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 56349b1650..9a28ed8f54 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -201,9 +201,9 @@ struct Namespaces { /// The open PUBLISH_NAMESPACE request carrying each advertised namespace. Empty when /// the entries ride a SUBSCRIBE_NAMESPACE stream inline. requests: HashMap>, - /// What our grant lets us publish (MoQ Auth), `None` while unknown. - permit: Option, - /// The grant union's epoch last applied to `permit`. + /// What we may advertise: our grant (MoQ Auth) and the ceiling on the peer. + permit: crate::auth::Permit, + /// The auth epoch last applied to `permit`. epoch: u64, } @@ -214,14 +214,14 @@ impl Namespaces { target, watched: HashMap::new(), requests: HashMap::new(), - permit: None, + permit: Default::default(), epoch: 0, } } - /// Whether our grant lets us advertise `path`. An unknown grant allows everything. + /// Whether we may advertise `path`. An unknown grant allows everything. fn permitted(&self, path: &crate::Path) -> bool { - self.permit.as_ref().is_none_or(|permit| permit.matches(path.as_str())) + self.permit.matches(path.as_str()) } } @@ -233,8 +233,8 @@ enum NamespaceEvent { Update(Option), /// The retry sleep fired: re-offer whatever the peer should be holding and isn't. Retry, - /// Our grant changed (MoQ Auth): re-check every namespace against it. - Regrant(Option), + /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. + Regrant(crate::auth::Permit), } #[derive(Clone)] @@ -1930,8 +1930,10 @@ where } // A grant change applies before the next update, so a namespace it no // longer covers is withdrawn rather than re-sent. - if let Poll::Ready(union) = self.auth.poll_union(epoch, waiter) { - return Poll::Ready(NamespaceEvent::Regrant(union)); + if let Poll::Ready(permit) = + self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) + { + return Poll::Ready(NamespaceEvent::Regrant(permit)); } if let Poll::Ready(update) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(update)); @@ -1965,8 +1967,8 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } - NamespaceEvent::Regrant(union) => { - ns.permit = union.map(|grant| grant.publish); + NamespaceEvent::Regrant(permit) => { + ns.permit = permit; let suffixes: Vec = ns.watched.keys().cloned().collect(); for suffix in suffixes { let path = prefix.join(&suffix); diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 496f954165..9d6a963996 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -154,6 +154,7 @@ where let control = Control::new(request_id_max, client); let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version); + // No AUTH on these drafts, but a narrowing still reaches both halves. let publisher = Publisher::new( runtime.clone(), adapter.clone(), @@ -162,7 +163,8 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), @@ -176,7 +178,8 @@ where version, tasks.clone(), goaway.going_away.clone(), - ); + ) + .with_auth(auth.clone()); // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control // stream. Parked on the drain trigger; races the transport close so diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d6c492c16d..e826192b28 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -393,8 +393,9 @@ struct BroadcastState { route: crate::origin::Route, // The served route: dropping it (and the serve task's clone) retracts the - // route and rejects its queued requests. - dynamic: crate::origin::Dynamic, + // route and rejects its queued requests. `None` once a narrowing took it away + // while the peer still advertises it. + dynamic: Option, // active number of PUBLISH_NAMESPACE messages. count: usize, @@ -1371,16 +1372,22 @@ where // tracks keep flowing. let entry = entry.into_mut(); entry.route = route.clone(); - entry.dynamic.update(route)?; + if let Some(dynamic) = &entry.dynamic { + dynamic.update(route)?; + } Ok(()) } Entry::Vacant(entry) => { + // Nothing the session no longer lets the peer publish is accepted. + if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + return Err(Error::Unauthorized); + } // Propagates Error::Unauthorized if the namespace is out of scope. let dynamic = self.origin.dynamic(&path, route.clone())?; entry.insert(BroadcastState { route, - dynamic, + dynamic: Some(dynamic), count: 1, sources: HashMap::new(), }); @@ -1443,6 +1450,7 @@ where async fn run_route(&self, path: PathOwned) { let mut broadcasts = TaskSet::owned(); let mut closed_session = self.session.clone(); + let mut epoch = 0; loop { let next = broadcasts .drive(|waiter| { @@ -1457,11 +1465,28 @@ where if self.going_away.poll(waiter).is_ready() { self.drain_route(&path); } + // A narrowing that excludes the namespace takes the route away as a + // retraction would, closing its sources. Tracks in flight end on their + // own gates, with `Unauthorized`. + let mut narrowed = false; + while let Poll::Ready(permit) = + self.auth.poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + { + narrowed |= !permit.within_ceiling(path.as_str()); + } // The route lives in the entry: stop_announce removing it retracts // the route, and this loop ends with it. let mut state = self.state.lock(); - match state.broadcasts.get_mut(&path) { - Some(entry) => entry.dynamic.poll_requested_broadcast(waiter).map(Some), + let Some(entry) = state.broadcasts.get_mut(&path) else { + return Poll::Ready(None); + }; + if narrowed && entry.dynamic.is_some() { + tracing::info!(route = %self.origin.absolute(&path), "namespace no longer authorized"); + entry.dynamic = None; + entry.sources.clear(); + } + match &mut entry.dynamic { + Some(dynamic) => dynamic.poll_requested_broadcast(waiter).map(Some), None => Poll::Ready(None), } }) @@ -1524,7 +1549,9 @@ where return; } entry.route.cost = crate::origin::Cost::DRAIN; - let _ = entry.dynamic.update(entry.route.clone()); + if let Some(dynamic) = &entry.dynamic { + let _ = dynamic.update(entry.route.clone()); + } } async fn run_broadcast(&self, path: Path<'_>, mut broadcast: broadcast::Dynamic) -> Result<(), Error> { diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 321af8bb81..edcb67d5d0 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -113,6 +113,12 @@ fn position_cursor(track: &mut track::Subscriber, version: Version, start_group: } impl Shared { + /// Watches whether the session still lets us serve `broadcast` to the peer: our + /// grant, and the ceiling on what the peer may subscribe to. + fn gate(&self, broadcast: &crate::Path) -> crate::auth::Gate { + crate::auth::Gate::new(self.auth.clone(), broadcast.to_owned(), crate::auth::Direction::Publish) + } + /// The origin to resolve a peer-requested broadcast from: excludes routes /// through the peer, so a subscription is never served data that flowed /// through the subscriber. The identity is the one the peer declared in its @@ -351,6 +357,9 @@ struct AuthServe { /// Shared with the app's [`crate::auth::Request`] / [`crate::auth::Issued`], or /// filled in by the default acceptor. issue: Option>, + /// The default acceptor's grant for the connection's credential, re-sent as a + /// narrowing changes it. `None` when the app answers. + default: Option, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, } @@ -365,6 +374,7 @@ impl AuthServe { shared, stream: Some(stream), issue: None, + default: None, finished: false, }) } @@ -412,8 +422,10 @@ impl AuthServe { // the presenter, the same as a peer that predates AUTH. None if !msg.token.is_empty() => return Poll::Ready(Err(Error::Unsupported)), None => { - let grant = self.shared.peer_grant.clone(); - issue.lock().outbox.push_back(crate::auth::Reply::Grant(grant)); + self.default = Some(crate::auth::DefaultGrant::new( + self.shared.auth.clone(), + self.shared.peer_grant.clone(), + )); } } self.issue = Some(issue.clone()); @@ -442,6 +454,12 @@ impl AuthServe { continue; } + if let Some(default) = &mut self.default + && let Poll::Ready(grant) = default.poll(waiter) + { + issue.lock().outbox.push_back(crate::auth::Reply::Grant(grant)); + } + let mut state = match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { true => Poll::Pending, false => Poll::Ready(()), @@ -776,9 +794,7 @@ impl AnnounceServe { }; let announced = origin.announced(); let mut run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); - if self.shared.version.has_auth() { - run.auth = Some(self.shared.auth.clone()); - } + run.auth = Some(self.shared.auth.clone()); self.state = AnnounceState::Run { origin, announced, run }; } } @@ -800,12 +816,12 @@ struct AnnounceRun { // prefix. The value is the announce id on versions that assign them. live: HashMap>, phase: AnnouncePhase, - // Our grant, on versions with AUTH: only what it lets us publish is announced, - // and a shrink withdraws what it no longer covers. + // Our grant and the ceiling on the peer: only what both let us publish is + // announced, and a shrink withdraws what they no longer cover. auth: Option, epoch: u64, - // What the union lets us publish, `None` until the peer first answers. - permit: Option, + // What we may announce right now. + permit: crate::auth::Permit, } enum AnnouncePhase { @@ -827,29 +843,26 @@ impl AnnounceRun { phase: AnnouncePhase::Init, auth: None, epoch: 0, - permit: None, + permit: Default::default(), } } - /// Whether our grant lets us announce `suffix` (relative to the requested prefix). + /// Whether we may announce `suffix` (relative to the requested prefix). fn permitted(&self, suffix: &crate::Path) -> bool { - self.permit - .as_ref() - .is_none_or(|permit| permit.matches(self.prefix.join(suffix).as_str())) + self.permit.matches(self.prefix.join(suffix).as_str()) } - /// Apply a change to our grant: withdraw what it no longer covers and, when it - /// grew, re-read the origin to announce what it now does. + /// Apply a change to what we may announce: withdraw what it no longer covers and, + /// when it grew, re-read the origin to announce what it now does. fn regrant( &mut self, stream: &mut Stream, origin: &origin::Consumer, announced: &mut announce::Consumer, - permit: crate::Patterns, + permit: crate::auth::Permit, ) -> Result<(), Error> { - // `None` allowed everything, so any grant only shrinks it. - let grew = self.permit.as_ref().is_some_and(|old| !old.covers(&permit)); - self.permit = Some(permit); + let grew = !self.permit.covers(&permit); + self.permit = permit; if !grew { let revoked: Vec<_> = self @@ -1009,7 +1022,7 @@ impl AnnounceRun { let suffix = update.prefix; if update.kind.is_active() { - if self.outgoing(&update.route, &absolute).is_none() { + if !self.permitted(&suffix) || self.outgoing(&update.route, &absolute).is_none() { continue; } tracing::debug!(route = %absolute, "announce"); @@ -1023,6 +1036,10 @@ impl AnnounceRun { } } + // The peer holds these now, so a later retraction or narrowing ends them. + for suffix in &init { + self.live.insert(suffix.clone(), None); + } let announce_init = lite::AnnounceInit { suffixes: init }; stream.writer.buffer(&announce_init)?; } @@ -1088,8 +1105,8 @@ impl AnnounceRun { // advertises something it would withdraw, or abort over, a moment later. if let Some(auth) = &self.auth { ready!(auth.poll_setup_answered(waiter)); - if let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) { - self.permit = union.map(|grant| grant.publish); + if let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) { + self.permit = permit; } } self.init(stream, origin, announced)?; @@ -1107,10 +1124,10 @@ impl AnnounceRun { // A grant change applies before the next update, so a route it no longer // covers is withdrawn rather than re-sent. if let Some(auth) = &self.auth - && let Poll::Ready(Some(grant)) = auth.poll_union(&mut self.epoch, waiter) - && self.permit.as_ref() != Some(&grant.publish) + && let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) + && self.permit != permit { - self.regrant(stream, origin, announced, grant.publish)?; + self.regrant(stream, origin, announced, permit)?; continue; } @@ -1179,6 +1196,8 @@ struct TrackInfoServe { shared: Arc>, stream: Option>, state: TrackInfoState, + /// Ends the request once the session stops allowing its broadcast. + gate: Option, // Log context, filled in after the decode. absolute: crate::PathOwned, track: String, @@ -1215,6 +1234,7 @@ impl TrackInfoServe { shared, stream: Some(stream), state: TrackInfoState::Decode, + gate: None, absolute: Default::default(), track: Default::default(), }) @@ -1234,6 +1254,9 @@ impl TrackInfoServe { | Error::Transport(_) => { tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info cancelled") } + Error::Unauthorized => { + tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info unauthorized") + } err => { tracing::warn!(broadcast = %self.absolute, track = %self.track, %err, "track info error") } @@ -1245,6 +1268,11 @@ impl TrackInfoServe { } fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } loop { match &mut self.state { TrackInfoState::Decode => { @@ -1255,12 +1283,11 @@ impl TrackInfoServe { self.track = msg.track.to_string(); tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info requested"); // Checked before anything is resolved, like a subscription. - let allowed = self - .shared - .auth - .allows(crate::auth::Direction::Publish, msg.broadcast.as_str()); + let mut gate = self.shared.gate(&msg.broadcast); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = TrackInfoState::Hop { msg }; - if !allowed { + if denied { return Poll::Ready(Err(Error::Unauthorized)); } } @@ -1413,22 +1440,15 @@ impl SubscribeServe { self.track = msg.track.to_string(); tracing::info!(id = self.id, broadcast = %self.absolute, track = %self.track, "subscribed started"); - if self.shared.version.has_auth() { - let mut gate = crate::auth::Gate::new( - self.shared.auth.clone(), - msg.broadcast.to_owned(), - crate::auth::Direction::Publish, - ); - // Checked before anything is resolved, so a broadcast our grant - // does not cover is never served, not just cut off later. - if gate.poll_denied(waiter).is_ready() { - self.state = SubscribeState::Hop { msg }; - return Poll::Ready(Err(Error::Unauthorized)); - } - self.gate = Some(gate); - } - + // Checked before anything is resolved, so a broadcast the session does + // not allow is never served, not just cut off later. + let mut gate = self.shared.gate(&msg.broadcast); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = SubscribeState::Hop { msg }; + if denied { + return Poll::Ready(Err(Error::Unauthorized)); + } } SubscribeState::Hop { .. } => { // We just received a subscribe for this exact path, so by definition the @@ -1565,6 +1585,8 @@ struct FetchServe { shared: Arc>, stream: Option>, state: FetchState, + /// Ends the fetch, even mid-group, once the session stops allowing its broadcast. + gate: Option, // Log context, filled in after the decode. absolute: crate::PathOwned, track: String, @@ -1618,6 +1640,7 @@ impl FetchServe { shared, stream: Some(stream), state: FetchState::Decode, + gate: None, absolute: Default::default(), track: Default::default(), group: 0, @@ -1640,6 +1663,9 @@ impl FetchServe { | Error::Transport(_) => { tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch cancelled") } + Error::Unauthorized => { + tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch unauthorized") + } err => { tracing::warn!(broadcast = %self.absolute, track = %self.track, group = %self.group, %err, "fetch error") } @@ -1651,6 +1677,11 @@ impl FetchServe { } fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } loop { match &mut self.state { FetchState::Decode => { @@ -1663,13 +1694,13 @@ impl FetchServe { self.group = msg.group; tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch started"); - // Checked before anything is resolved, like a subscription. - let allowed = self - .shared - .auth - .allows(crate::auth::Direction::Publish, msg.broadcast.as_str()); + // Checked before anything is resolved, like a subscription, and held + // until the last frame. + let mut gate = self.shared.gate(&msg.broadcast); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = FetchState::Hop { msg }; - if !allowed { + if denied { return Poll::Ready(Err(Error::Unauthorized)); } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1ee11b7f9f..2cb602d0a7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -326,7 +326,7 @@ impl Subscriber { // outside our scope, so don't serve it. Reflections are already // filtered above. let route = self.announced_route(hops, cost, link_cost, responder_origin); - let Ok(dynamic) = self.origin.dynamic(&path, route.clone()) else { + let Some(dynamic) = self.serve_route(&path, route.clone()) else { return Ok(false); }; @@ -335,6 +335,17 @@ impl Subscriber { Ok(true) } + /// Announce the peer's route into the origin, so paths under it resolve through + /// this session on demand. `None` when we may not accept it: outside our origin's + /// scope, or outside what the session still lets the peer publish. + fn serve_route(&self, path: &Path, route: crate::origin::Route) -> Option { + if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + tracing::debug!(route = %self.log_path(path), "declining announce outside the narrowed grant"); + return None; + } + self.origin.dynamic(path, route).ok() + } + /// The route to announce for a prefix this peer advertised, charging our /// link's price on top of the cost it advertised. /// @@ -428,7 +439,7 @@ impl Subscriber { return Ok(true); } - let Ok(dynamic) = self.origin.dynamic(&path, metadata.clone()) else { + let Some(dynamic) = self.serve_route(&path, metadata.clone()) else { announced.declined(path); return Ok(false); }; @@ -1152,6 +1163,8 @@ struct PrefixRun { // path, and lite-07 bases name it too. Tracked even for announces we drop // locally (reflected loops), since the sender doesn't know we dropped them. decoder: lite::AnnounceDecoder, + /// The auth epoch last applied, so a narrowing drops the routes it excludes. + epoch: u64, } impl AnnouncePrefix { @@ -1238,6 +1251,7 @@ impl AnnouncePrefix { link_cost, announced: Announced::default(), decoder: lite::AnnounceDecoder::default(), + epoch: 0, }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they @@ -1278,6 +1292,15 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } + // A narrowing aborts what the peer may no longer publish to us. The + // peer still holds each advertisement, so the path stays reserved. + while let Poll::Ready(permit) = + self.subscriber + .auth + .poll_permit(crate::auth::Direction::Subscribe, &mut run.epoch, waiter) + { + run.announced.narrow(&permit, &self.subscriber); + } loop { match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { @@ -1418,10 +1441,11 @@ mod tests { .subscribe(None) .await .unwrap(); + let gate = serve.gate(); let mut running = TrackServeRun { serve, state: TrackRunState::Serve(serving), - gate: None, + gate, }; assert!( kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready(), @@ -1655,6 +1679,59 @@ mod tests { assert!(wire.is_empty(), "a second SUBSCRIBE trailed the first"); } + /// A narrowing that lands while the SUBSCRIBE is still opening resets it with + /// UNAUTHORIZED, like a live one, and releases its id. + #[tokio::test] + async fn a_narrowing_resets_a_subscribe_still_opening() { + // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. + let gate = kio::Producer::new(false); + let session = SinkSession::gated_bi(gate.consume()); + let auth = crate::auth::Handle::new(false); + + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: session.clone(), + origin, + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + peer_hop: None, + cost: None, + going_away: Default::default(), + auth: auth.clone(), + }); + let subscribes = subscriber.subscribes.clone(); + let serve = TrackServe { + subscriber, + path: Path::new("room/host").to_owned(), + name: "audio".to_string(), + }; + + let broadcast = crate::broadcast::Info::new().produce(); + let request = broadcast.reserve_track("audio").unwrap(); + let mut serving = ServeLoop::new(&serve, request, Default::default(), Some(Timescale::default())); + let establish = serve.prepare_establish(&mut serving.serving, Subscription::default(), Some(Timescale::default())); + serving.mode = ServeMode::Establish(establish); + let gate_run = serve.gate(); + let mut running = TrackServeRun { + serve, + state: TrackRunState::Serve(serving), + gate: gate_run, + }; + + assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); + assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); + + auth.narrow(&crate::auth::Grant::default()); + assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); + + let unauthorized = crate::StreamError::Unauthorized.to_code(); + assert_eq!(session.log.resets(), vec![unauthorized]); + assert!(subscribes.lock().is_empty(), "the id outlived its subscription"); + drop(gate); + } + /// Everything a `handle_subscription` test needs to stay alive for the call. struct Harness { serve: TrackServe, @@ -2846,6 +2923,17 @@ impl Announced { } } + /// Drop every attached route the session no longer lets the peer publish, closing + /// its sources. Tracks in flight end on their own gates, with `Unauthorized`. + fn narrow(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { + for (path, route) in self.routes.iter_mut() { + if route.is_some() && !permit.within_ceiling(path.as_str()) { + tracing::info!(route = %subscriber.log_path(path), "announce no longer authorized"); + *route = None; + } + } + } + /// Re-price every attached route to a draining cost (the peer sent a GOAWAY). fn drain(&mut self) { for entry in self.routes.values_mut().flatten() { @@ -2951,6 +3039,16 @@ struct TrackServe { } impl TrackServe { + /// Watches whether the session still lets us receive this broadcast from the peer: + /// our grant, and the ceiling on what the peer may publish. + fn gate(&self) -> crate::auth::Gate { + crate::auth::Gate::new( + self.subscriber.auth.clone(), + self.path.clone(), + crate::auth::Direction::Subscribe, + ) + } + fn widen_frame_bounds(&self, subscription: &mut Subscription) { if self.subscriber.version.has_frame_bounds() { return; @@ -3283,6 +3381,14 @@ impl Establish { } } + /// Give up on the SUBSCRIBE, resetting it with `err` if it reached the wire. + fn abort(self, err: &Error) { + self.serve.subscriber.remove_subscribe(self.id); + if let EstablishState::Send { stream } | EstablishState::WaitOk { stream } = self.state { + stream.writer.abort(err); + } + } + fn activate(&self, stream: Stream) -> SubStream { SubStream { stream, @@ -3303,8 +3409,8 @@ impl Establish { struct TrackServeRun { serve: TrackServe, state: TrackRunState, - /// Cancels the track once our grant stops covering its broadcast. - gate: Option, + /// Cancels the track once the session stops allowing its broadcast. + gate: crate::auth::Gate, } // A state machine's enum is its storage: one transient instance per stream, so the @@ -3335,18 +3441,12 @@ impl TrackServeRun { let info = track::Info::default().with_max_age(serve.subscriber.origin.default_max_age()); TrackRunState::Serve(ServeLoop::new(&serve, request, info, None)) }; - let gate = serve.subscriber.version.has_auth().then(|| { - crate::auth::Gate::new( - serve.subscriber.auth.clone(), - serve.path.clone(), - crate::auth::Direction::Subscribe, - ) - }); + let gate = serve.gate(); Self { serve, state, gate } } - /// Our grant no longer covers the broadcast: end the track and cancel the - /// upstream subscription, leaving the rest of the session alone. + /// The session no longer allows the broadcast: end the track and cancel the + /// upstream subscription and fetches, leaving the rest of the session alone. fn revoke(&mut self) { tracing::info!(broadcast = %self.serve.subscriber.log_path(&self.serve.path), track = %self.serve.name, "subscription no longer authorized"); match std::mem::replace(&mut self.state, TrackRunState::Done) { @@ -3358,11 +3458,15 @@ impl TrackServeRun { TrackRunState::Serve(serve_loop) => { let _ = serve_loop.serving.abort(Error::Unauthorized); // Reset rather than finish, so the publisher reads a revocation instead of - // a routine unsubscribe. + // a routine unsubscribe. A SUBSCRIBE still opening is on the wire too. if let Sub::Active(active) = serve_loop.sub { self.serve.subscriber.remove_subscribe(active.id); active.stream.writer.abort(&Error::Unauthorized); } + if let ServeMode::Establish(establish) = serve_loop.mode { + establish.abort(&Error::Unauthorized); + } + // Each fetch in flight resets itself as it drops, seeing the same denial. } TrackRunState::Done => {} } @@ -3371,9 +3475,7 @@ impl TrackServeRun { impl kio::Task for TrackServeRun { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - if let Some(gate) = &mut self.gate - && gate.poll_denied(waiter).is_ready() - { + if self.gate.poll_denied(waiter).is_ready() { self.revoke(); return Poll::Ready(()); } @@ -3870,6 +3972,45 @@ impl FetchServeRun { } } +impl Drop for FetchServeRun { + fn drop(&mut self) { + // Dropped because the session stopped allowing the broadcast: reset with that + // reason, so neither the publisher nor the waiting reader reads a routine cancel. + // Any other drop keeps the default cancel. + if matches!(self.state, FetchRunState::Done) + || self + .serve + .subscriber + .auth + .allows(crate::auth::Direction::Subscribe, self.serve.path.as_str()) + { + return; + } + let err = Error::Unauthorized; + match std::mem::replace(&mut self.state, FetchRunState::Done) { + FetchRunState::Open { request } => { + if let Some(request) = request { + request.reject(err); + } + } + FetchRunState::Send { request, stream, .. } | FetchRunState::Answer { request, stream, .. } => { + stream.writer.abort(&err); + if let Some(request) = request { + request.reject(err); + } + } + FetchRunState::Ingest { + mut stream, producer, .. + } => { + stream.reader.abort(&err); + stream.writer.abort(&err); + let _ = producer.abort(err); + } + FetchRunState::Done => {} + } + } +} + impl kio::Task for FetchServeRun { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { let mut cx = std::task::Context::from_waker(waiter.waker()); diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 2cd2592cd4..7fbae7dd79 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -162,6 +162,7 @@ struct Pair { client: Session, server: Session, client_transport: MockSession, + server_transport: MockSession, requests: Option, } @@ -186,6 +187,7 @@ async fn connect(opts: Options) -> Pair { } let observe = client_transport.clone(); + let observe_server = server_transport.clone(); let client_fut = async { let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); tokio::spawn(run(driver)); @@ -210,6 +212,7 @@ async fn connect(opts: Options) -> Pair { client, server, client_transport: observe, + server_transport: observe_server, requests, } } @@ -927,3 +930,232 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { .await .expect("timed out"); } + +/// Run each narrowing case on every version family: the session enforces a narrowing +/// itself, so a peer without AUTH (or one that ignores it) cannot keep what it lost. +macro_rules! narrow_cases { + ($($case:ident),* $(,)?) => { + mod narrow_lite_05 { + $(#[tokio::test] async fn $case() { super::$case("moq-lite-05").await })* + } + mod narrow_lite_06 { + $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* + } + mod narrow_moqt_16 { + $(#[tokio::test] async fn $case() { super::$case("moq-transport-16").await })* + } + mod narrow_moqt_17 { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + }; +} + +narrow_cases!( + a_narrowing_deafens_one_path, + a_narrowing_aborts_what_the_peer_published +); + +#[tokio::test] +async fn lite_05_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight("moq-lite-05").await +} + +#[tokio::test] +async fn lite_06_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight(LITE_06).await +} + +/// Whether `version` exchanges AUTH, so the peer also hears of a narrowing. +fn speaks_auth(version: &str) -> bool { + matches!(version, LITE_06 | MOQT_17 | MOQT_22) +} + +/// A revocation as the reader sees it: the local gate's own error, or the peer's +/// UNAUTHORIZED reset relayed across the splice. +fn unauthorized(err: &Error) -> bool { + matches!(err, Error::Unauthorized | Error::Stream(StreamError::Unauthorized)) +} + +/// Read groups until the subscription ends, returning how it ended. +async fn ended(sub: &mut moq_net::track::Subscriber) -> Error { + loop { + match sub.recv_group().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("subscription finished instead of ending"), + Err(err) => return err, + } + } +} + +/// The deafen case: narrowing a live session away from one audio path resets that +/// subscription and retracts its announcement, while a sibling under the same prefix +/// keeps flowing and the session stays up. +async fn a_narrowing_deafens_one_path(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let relay = produce_origin(1); + let audio = relay.create_broadcast("room/alice/audio").unwrap(); + let audio_track = audio.create_track("opus", None).unwrap(); + audio.announce(Default::default()).unwrap(); + let video = relay.create_broadcast("room/alice/video").unwrap(); + let video_track = video.create_track("h264", None).unwrap(); + video.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), + ..Default::default() + }) + .await; + + let mut group = audio_track.append_group().unwrap(); + group.write_frame(ts(0), b"a".as_ref()).unwrap(); + let mut group = video_track.append_group().unwrap(); + group.write_frame(ts(0), b"v".as_ref()).unwrap(); + + let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); + let mut audio_sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + audio_sub.recv_group().await.unwrap().unwrap(); + let remote = received.consume().routed_broadcast("room/alice/video").await.unwrap(); + let mut video_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); + video_sub.recv_group().await.unwrap().unwrap(); + + pair.server.auth().narrow(&grant(&[], &["room/alice/video"])); + + let err = ended(&mut audio_sub).await; + assert!(unauthorized(&err), "{err:?}"); + wait_announced(&received.consume(), "room/alice/audio", false).await; + + // The sibling keeps flowing. + let mut group = video_track.append_group().unwrap(); + group.write_frame(ts(1), b"v".as_ref()).unwrap(); + let group = video_sub.recv_group().await.unwrap().expect("video still flows"); + assert_eq!(group.sequence, 1); + + // The relay enforced it, not the client: moq-lite resets the subscription with + // UNAUTHORIZED, and neither side closed the session. + if version.starts_with("moq-lite") { + let resets = pair.server_transport.resets(); + assert!(resets.contains(&StreamError::Unauthorized.to_code()), "{resets:x?}"); + } + assert_eq!(pair.client_transport.close_reason(), None); + + // A peer that speaks AUTH is told what it may still subscribe to. + if speaks_auth(version) { + let narrowed = wait_for(pair.client.auth().grant(), |grant| { + grant + .as_ref() + .is_some_and(|grant| grant.subscribe == patterns(&["room/alice/video"])) + }) + .await; + assert_eq!(narrowed, Some(grant(&[], &["room/alice/video"]))); + } + }) + .await + .expect("timed out"); +} + +/// Narrowing what the peer may publish aborts the broadcasts it published outside, +/// so the relay's own readers see `Unauthorized`, and retracts their routes, while +/// what it may still publish keeps flowing. +async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let client_origin = produce_origin(2); + let mic = client_origin.create_broadcast("room/bob/mic").unwrap(); + let mic_track = mic.create_track("opus", None).unwrap(); + mic.announce(Default::default()).unwrap(); + let cam = client_origin.create_broadcast("room/bob/cam").unwrap(); + let cam_track = cam.create_track("h264", None).unwrap(); + cam.announce(Default::default()).unwrap(); + + let relay = produce_origin(1); + let pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(relay.clone()), + ..Default::default() + }) + .await; + + let mut group = mic_track.append_group().unwrap(); + group.write_frame(ts(0), b"m".as_ref()).unwrap(); + let mut group = cam_track.append_group().unwrap(); + group.write_frame(ts(0), b"c".as_ref()).unwrap(); + + let remote = relay.consume().routed_broadcast("room/bob/mic").await.unwrap(); + let mut mic_sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + mic_sub.recv_group().await.unwrap().unwrap(); + let remote = relay.consume().routed_broadcast("room/bob/cam").await.unwrap(); + let mut cam_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); + cam_sub.recv_group().await.unwrap().unwrap(); + + pair.server.auth().narrow(&grant(&["room/bob/cam"], &[])); + + let err = ended(&mut mic_sub).await; + assert!(unauthorized(&err), "{err:?}"); + wait_announced(&relay.consume(), "room/bob/mic", false).await; + + let mut group = cam_track.append_group().unwrap(); + group.write_frame(ts(1), b"c".as_ref()).unwrap(); + let group = cam_sub.recv_group().await.unwrap().expect("cam still flows"); + assert_eq!(group.sequence, 1); + + // A narrowing is not a publication outside the grant: the client stays up. + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// A fetch still streaming its group when the grant narrows away from it is reset with +/// UNAUTHORIZED by the publisher, not left to finish. +async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + + let relay = produce_origin(1); + let broadcast = relay.create_broadcast("room/x").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + // The group stays open, so the fetch is still in flight when the grant narrows. + let mut group = track.append_group().unwrap(); + group.write_frame(ts(0), b"first".as_ref()).unwrap(); + + let received = produce_origin(3); + let pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + server_publish: Some(relay.clone()), + ..Default::default() + }) + .await; + + let remote = received.consume().routed_broadcast("room/x").await.unwrap(); + let mut fetched = remote.track("video").unwrap().fetch_group(0, None).await.unwrap(); + let frame = fetched.read_frame().await.unwrap().expect("first frame"); + assert_eq!(frame.payload.as_ref(), b"first"); + + pair.server.auth().narrow(&grant(&[], &["room/y"])); + + let err = loop { + match fetched.read_frame().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("fetch finished instead of ending"), + Err(err) => break err, + } + }; + assert!(unauthorized(&err), "{err:?}"); + let resets = pair.server_transport.resets(); + assert!(resets.contains(&StreamError::Unauthorized.to_code()), "{resets:x?}"); + drop(group); + }) + .await + .expect("timed out"); +} From e34649c4bf104a551c4207f8a46a03858c0cace9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:56:31 -0700 Subject: [PATCH 03/11] feat(relay): narrow a live session on a narrower re-check Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 78 ++++++++++++++++++ rs/moq-relay/src/auth.rs | 56 +++++++++++-- rs/moq-relay/src/connection.rs | 13 +-- rs/moq-relay/src/websocket.rs | 3 +- rs/moq-relay/tests/auth_lifetime.rs | 123 ++++++++++++++++++++++++++-- 5 files changed, 251 insertions(+), 22 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index c12b0f3a6a..2833c5b703 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -987,3 +987,81 @@ impl Enforce { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn patterns(prefixes: &[&str]) -> Patterns { + prefixes + .iter() + .map(|prefix| crate::Pattern::subtree(prefix).unwrap()) + .collect() + } + + fn grant(publish: &[&str], subscribe: &[&str]) -> Grant { + Grant { + publish: patterns(publish), + subscribe: patterns(subscribe), + expires: None, + } + } + + /// A narrowing keeps only what every narrowing so far allows, so a wider one + /// changes nothing, and it applies to a version without AUTH too. + #[test] + fn a_narrowing_only_ever_narrows() { + let handle = Handle::new(false); + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + + handle.narrow(&grant(&["room/bob"], &["room/alice"])); + // What we send is what the peer may subscribe to, and the other way around. + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + assert!(!handle.allows(Direction::Publish, "room/bob/cam")); + assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); + assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); + + handle.narrow(&grant(&[""], &["room/alice/video"])); + assert!(!handle.allows(Direction::Publish, "room/alice/audio")); + assert!(handle.allows(Direction::Publish, "room/alice/video")); + assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); + + handle.narrow(&Grant::all()); + assert!(!handle.allows(Direction::Publish, "room/alice/audio")); + assert!(!handle.allows(Direction::Subscribe, "other")); + } + + /// Our own grant does not decide what the peer may offer us, only the ceiling does. + #[test] + fn the_ceiling_alone_decides_what_the_peer_offers() { + let handle = Handle::new(true); + let token = handle.present(Bytes::new(), true).unwrap(); + handle.granted(0, grant(&[], &[])); + assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); + + handle.narrow(&grant(&["room/alice"], &[])); + assert!(!handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_ceiling(Direction::Subscribe, "room/alice/cam")); + drop(token); + } + + /// The default acceptor answers with its base grant, then with each grant a + /// narrowing changes, and stays quiet on a change that leaves it alone. + #[test] + fn the_default_grant_follows_the_ceiling() { + let handle = Handle::new(true); + let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); + let waiter = kio::Waiter::noop(); + + assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); + assert!(default.poll(&waiter).is_pending()); + + handle.narrow(&grant(&["room"], &["room/alice"])); + assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); + + // Wider than the ceiling already is: nothing to tell the peer. + handle.narrow(&Grant::all()); + assert!(default.poll(&waiter).is_pending()); + } +} diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 018086750c..09864a8292 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -412,14 +412,21 @@ impl Token { } /// Whether `other` still covers everything this token scopes: the same root and - /// mounts, and every grant still held. A narrower re-check closes the session until - /// pattern scopes can resize it in place. + /// mounts, and every grant still held. A narrower re-check narrows the session. pub(crate) fn covered_by(&self, other: &Self) -> bool { self.root == other.root && self.mounts == other.mounts && other.subscribe.covers(&self.subscribe) && other.publish.covers(&self.publish) } + + /// Keep only what `other` also grants. Fails closed: an intersection too large + /// to hold grants nothing. + fn narrow(&mut self, other: &Self) { + let both = |this: &Patterns, other: &Patterns| this.intersect(other).unwrap_or_default(); + self.subscribe = both(&self.subscribe, &other.subscribe); + self.publish = both(&self.publish, &other.publish); + } } /// What admitted a session and keeps it admitted: the grant's lease and the @@ -432,6 +439,8 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, + /// The live session a narrower re-check narrows in place, once attached. + session: Option, /// When the grant runs out, enforced here whoever drives the lease: a fixed /// grant has no driver, and an auth server's may be mid-outage. Fixed on tokio's /// clock when the grant arrives, so re-polling [`ended`](Self::ended) never @@ -450,17 +459,26 @@ impl Lease { token: Token::new(path, &grant), expires: grant.deadline(), consumer, + session: None, stats: Default::default(), } } + /// Narrow `session` in place when a re-check narrows the grant, instead of ending + /// the lease. Without it a narrower grant ends the lease. + pub(crate) fn narrowing(mut self, session: &moq_net::Session) -> Self { + self.session = Some(session.auth()); + self + } + /// Attach the session's stats context, so a re-checked tier retags it live. pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self { self.stats = stats; self } - /// The scope the session was admitted under. + /// The scope the session holds: what it was admitted under, less what any + /// re-check narrowed away since. pub fn token(&self) -> &Token { &self.token } @@ -473,11 +491,13 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A changed root or mounts, or a narrower grant, ends it: origin handles cannot yet narrow - /// a live scope in place (tracked by `quest/m1/auth/narrowing.md`). A flipped - /// `peer` ends it too, since the routes it already announced would be - /// misreported as entering here or from a peer. A changed tier keeps the - /// session and moves its [stats](Self::with_stats) to the new tier. + /// A narrower grant with the same root and mounts narrows an attached session in + /// place and keeps going; the session keeps only what both grants allow, so a + /// wider part waits for a reconnect. Without an attached session it ends the lease. + /// A changed root or mounts ends it, and so does a flipped `peer`, since the + /// routes it already announced would be misreported as entering here or from a + /// peer. A changed tier keeps the session and moves its + /// [stats](Self::with_stats) to the new tier. pub async fn ended(&mut self) -> lease::Reason { loop { let expire = async { @@ -502,7 +522,20 @@ impl Lease { return "peer changed".into(); } if !self.token.covered_by(&fresh) { - return "grant narrowed".into(); + let Some(session) = &self.session else { + return "grant narrowed".into(); + }; + self.token.narrow(&fresh); + tracing::info!( + publish = %join(&self.token.publish), + subscribe = %join(&self.token.subscribe), + "grant narrowed; narrowing the session", + ); + session.narrow(&moq_net::auth::Grant { + publish: self.token.publish.clone(), + subscribe: self.token.subscribe.clone(), + expires: None, + }); } if fresh.tier != self.token.tier { tracing::info!(from = %self.token.tier, to = %fresh.tier, "tier changed"); @@ -526,6 +559,11 @@ impl Lease { } } +/// Patterns as one comma-separated line, for logs. +fn join(patterns: &Patterns) -> String { + patterns.iter().map(|pattern| pattern.as_str()).collect::>().join(",") +} + enum Decider { Server(moq_auth::Client), /// The public rules, as a token with an empty root. diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 8d5e460c84..f143a818f3 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -275,20 +275,21 @@ pub(crate) fn authorize( /// the internal listener re-checks this lease; `None` for a session that is /// not in the table. /// -/// The lease is the decider's live word on the grant: when it stops covering -/// the session ([`auth::Lease::ended`]) the session closes with the reason, and -/// the session's own close is reported back through the lease as the `end` event. -/// Either way, a relay shutdown drains the session with a GOAWAY instead of -/// cutting it off. +/// The lease is the decider's live word on the grant: a narrower re-check narrows +/// the session in place, and when it stops covering the session +/// ([`auth::Lease::ended`]) the session closes with the reason. The session's own +/// close is reported back through the lease as the `end` event. Either way, a relay +/// shutdown drains the session with a GOAWAY instead of cutting it off. /// /// The session handle is `Send + Sync` whatever transport carries it, so this /// runs on the shared runtime even for sessions a pinned QUIC worker drives. pub async fn supervise( session: moq_net::Session, - mut lease: auth::Lease, + lease: auth::Lease, mut shutdown: crate::shutdown::Observer, registration: Option, ) -> anyhow::Result<()> { + let mut lease = lease.narrowing(&session); loop { let nudged = async { match ®istration { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 6093d0ba95..192051ae49 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -127,7 +127,7 @@ struct SessionInputs { async fn handle_socket( socket: T, session: SessionInputs, - mut lease: auth::Lease, + lease: auth::Lease, pending: Option<(crate::session::Registry, moq_auth::Request)>, ) -> anyhow::Result<()> where @@ -197,6 +197,7 @@ where // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); + let mut lease = lease.narrowing(&session); loop { let nudged = async { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index a61e794a67..16c3c0049d 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -555,9 +555,11 @@ async fn a_moved_tier_retags_the_live_session() { relay.abort(); } -/// A narrower grant closes the session on the next re-check, over TCP and WebSocket. +/// A narrower grant narrows the live session in place on the next re-check, over TCP +/// and WebSocket: the deafened path resets with `Unauthorized`, a sibling under the +/// same prefix keeps flowing, and neither session closes. #[tokio::test] -async fn a_narrower_grant_closes_live_sessions() { +async fn a_narrower_grant_narrows_live_sessions() { for scheme in ["tcp", "ws"] { let script = Script::new(grant(Duration::from_secs(3600))); let auth = build_auth(script.spawn().await); @@ -565,18 +567,127 @@ async fn a_narrower_grant_closes_live_sessions() { "tcp" => spawn_relay(auth).await, _ => spawn_ws_relay(auth).await, }; - let (pub_session, sub_session) = connect_and_round_trip(&room_url(scheme, port)).await; + let url = room_url(scheme, port); + + let pub_origin = moq_tokio::origin::spawn(); + let mut tracks = Vec::new(); + for path in ["alice/audio", "alice/video"] { + let broadcast = pub_origin.create_broadcast(path).expect("create broadcast"); + let track = broadcast.create_track("media", None).expect("create track"); + broadcast.announce(Default::default()).expect("announce broadcast"); + tracks.push((broadcast, track)); + } + let pub_session = tokio::time::timeout( + TIMEOUT, + client() + .with_publisher(pub_origin.consume()) + .with_reconnect(false) + .connect(url.clone()) + .established(), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); + + let sub_origin = moq_tokio::origin::spawn(); + let sub_consumer = sub_origin.consume(); + let sub_session = tokio::time::timeout( + TIMEOUT, + client() + .with_subscriber(sub_origin) + .with_reconnect(false) + .connect(url.clone()) + .established(), + ) + .await + .expect("subscriber connect timeout") + .expect("subscriber connect failed"); + + let mut subs = Vec::new(); + for path in ["alice/audio", "alice/video"] { + let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.routed_broadcast(path)) + .await + .expect("announcement timeout") + .expect("announced broadcast resolves"); + subs.push(broadcast.track("media").unwrap().subscribe(None).await.expect("subscribe")); + } + let send = |index: usize, sequence: u64| { + let mut group = tracks[index].1.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, b"media".as_ref()) + .expect("write frame"); + group.finish().expect("finish group"); + assert_eq!(group.sequence, sequence); + }; + for index in 0..2 { + send(index, 0); + tokio::time::timeout(TIMEOUT, subs[index].recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("track closed prematurely"); + } + // Deafen alice's audio: everything else stays as admitted. let mut narrow = grant(Duration::from_secs(3600)); - narrow.publish = ["nobody/**".parse().unwrap()].into_iter().collect(); + narrow.subscribe = ["alice/video/**".parse().unwrap()].into_iter().collect(); script.on_revalidate(Answer::Grant(narrow)); - assert_closed(pub_session, Duration::from_secs(5), &format!("{scheme} publisher")).await; - assert_closed(sub_session, Duration::from_secs(5), &format!("{scheme} subscriber")).await; + let err = loop { + match tokio::time::timeout(TIMEOUT, subs[0].recv_group()) + .await + .expect("the deafened track never ended") + { + Ok(Some(_)) => continue, + Ok(None) => panic!("{scheme}: the deafened track finished instead of ending"), + Err(err) => break err, + } + }; + assert!( + matches!( + err, + moq_net::Error::Unauthorized | moq_net::Error::Stream(moq_net::StreamError::Unauthorized) + ), + "{scheme}: {err:?}" + ); + + send(1, 1); + let group = tokio::time::timeout(TIMEOUT, subs[1].recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("the sibling track closed"); + assert_eq!(group.sequence, 1, "{scheme}"); + + for (session, what) in [(pub_session, "publisher"), (sub_session, "subscriber")] { + assert!( + tokio::time::timeout(Duration::from_millis(200), session.closed()) + .await + .is_err(), + "{scheme}: a narrowing must not close the {what}" + ); + } relay.abort(); } } +/// A re-check that moves the root still closes the session: nothing it holds is +/// named the same way under the new one. +#[tokio::test] +async fn a_moved_root_closes_live_sessions() { + let script = Script::new(grant(Duration::from_secs(3600))); + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + let (pub_session, sub_session) = connect_and_round_trip(&room_url("tcp", port)).await; + + let mut moved = grant(Duration::from_secs(3600)); + moved.root = Some("elsewhere".into()); + script.on_revalidate(Answer::Grant(moved)); + + assert_closed(pub_session, Duration::from_secs(5), "publisher").await; + assert_closed(sub_session, Duration::from_secs(5), "subscriber").await; + relay.abort(); +} + /// A refusal on re-check closes the session, and the `end` says why. /// 403, 401, and an empty grant are all a no. #[tokio::test] From e2891ba144ac2a7c9ee646be623eab75dde99ff6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:02:06 -0700 Subject: [PATCH 04/11] fix(net): arm JS grant watches before a request's first await Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 11 +++- doc/lib/rs/moq-net.md | 18 ++++++ js/net/src/lite/publisher.test.ts | 94 ++++++++++++++++++++++++++++++ js/net/src/lite/publisher.ts | 89 +++++++++++++++++++++++----- js/net/src/lite/subscriber.test.ts | 31 ++++++++++ js/net/src/lite/subscriber.ts | 58 +++++++++++++----- 6 files changed, 269 insertions(+), 32 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 096a5159c8..4bf255d303 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -64,9 +64,14 @@ The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the -same request. A grant applies: a changed `root` or `mounts`, or one that no longer covers -what the session holds closes it with `Unauthorized` (the live session is not -resized in place), and so does a flipped `peer`; a changed `tier` keeps the +same request. A grant applies: one that no longer covers what the session holds +narrows the live session in place to what both grants allow, so a moderation +decision lands on the session it targets. Its subscriptions and fetches outside +the new grant reset with `Unauthorized`, the broadcasts it published outside +abort, and everything else keeps flowing; a wider part waits for a reconnect. A +one-shot HTTP `/fetch` ends instead. A +changed `root` or `mounts` closes the session with `Unauthorized`, and so does a +flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then on, as does each group and subscription it starts afterwards, while one already in flight finishes where it began. A 401 or 403 closes the session now, as does a 2xx diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index f8b8d09451..2b4b3bbe06 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -104,6 +104,24 @@ shrinks withdraws the announcements and cancels the subscriptions it no longer covers, and leaves the session up. On moq-lite each cancelled stream resets with `StreamError::Unauthorized`. +`session.auth().narrow(&grant)` takes access away from the peer on a live session, +on every version, whether or not the peer speaks AUTH: the session enforces it +itself. It only ever narrows, keeping what both the current ceiling and `grant` +allow. What falls outside ends at once: announcements to the peer retract, its +new requests are refused, its subscriptions and fetches reset with +`Unauthorized`, and the broadcasts it published abort, so local readers see +`Unauthorized` too. The rest of the session carries on. When the session answers +the peer's connection credential itself, the peer is sent the narrowed grant. + +```rust +// Deafen alice's audio: the peer may still subscribe to her video. +let video: Pattern = "room/alice/video/**".parse()?; +session.auth().narrow(&auth::Grant { + subscribe: video.into(), + ..auth::Grant::all() +}); +``` + ## Patterns `Pattern` describes a set of paths; `Patterns` is a union reduced by diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 2d08823181..560e3d20fb 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1,5 +1,8 @@ import { expect, mock, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; +import { Producer as BroadcastProducer } from "../broadcast.ts"; +import { StreamCode, toStreamCode } from "../error.ts"; import { Producer as GroupProducer } from "../group.ts"; import { randomHop } from "../hop.ts"; import { hooks } from "../internal.ts"; @@ -1603,3 +1606,94 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi origin.close(); } }); + +function grantOf(publish: string): Grant { + return { publish: new Path.Patterns([Path.Pattern.subtree(publish)]), subscribe: new Path.Patterns([]) }; +} + +// The resets carrying our UNAUTHORIZED code, by message. +function unauthorizedResets(resets: { mock: { calls: unknown[][] } }): string[] { + return resets.mock.calls + .map(([reason]) => reason as Error) + .filter((reason) => toStreamCode(reason) === StreamCode.Unauthorized) + .map((reason) => reason.message); +} + +// The grant watch is armed before the first check, so a shrink that lands while the broadcast +// is still resolving resets the subscription instead of being missed for good. +test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const handle = origin.dynamic(Path.from("live")); + const requests = handle.requested(); + const grant = new Signal(grantOf("live")); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), grant); + + const client = await Stream.open(pair.client); + const server = await Stream.accept(pair.server); + if (!server) throw new Error("publisher never accepted the subscribe stream"); + + const resets = spyOn(Writer.prototype, "reset"); + const produced = new BroadcastProducer(); + try { + const msg = new Subscribe({ id: 0n, broadcast: Path.from("live/cam"), track: "video", priority: 0 }); + const serving = publisher.runSubscribe(msg, server); + + // The subscription is parked resolving the broadcast when the grant shrinks. + const request = await requests.next(); + if (request.done) throw new Error("the handler never saw the request"); + grant.set(grantOf("other")); + await Promise.resolve(); + request.value.accept(produced); + await serving; + + expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam"); + } finally { + resets.mockRestore(); + produced.close(); + handle.close(); + publisher.close(); + client.close(); + origin.close(); + } +}); + +// A fetch holds its grant watch until the last frame: a group can stay open as long as its +// track, so a check at accept alone would keep serving after a shrink. +test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const grant = new Signal(grantOf("live")); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), grant); + + const broadcast = publish(origin, Path.from("live")); + const track = broadcast.createTrack("video"); + // Left open, so the fetch is still serving when the grant shrinks. + const group = new GroupProducer(0); + group.writeString("first"); + track.writeGroup(group); + + const client = await Stream.open(pair.client); + const server = await Stream.accept(pair.server); + if (!server) throw new Error("publisher never accepted the fetch stream"); + + const resets = spyOn(Writer.prototype, "reset"); + try { + const msg = new Fetch({ broadcast: Path.from("live"), track: "video", priority: 0, group: 0 }); + const serving = publisher.runFetch(msg, server); + // The first frame is on the wire before the grant shrinks. + await client.reader.u8(); + + grant.set(grantOf("other")); + await serving; + + expect(unauthorizedResets(resets)).toContain("unauthorized: live"); + } finally { + resets.mockRestore(); + group.close(); + publisher.close(); + client.close(); + broadcast.close(); + origin.close(); + } +}); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 7a7d937179..b4e61c9d26 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -569,10 +569,46 @@ export class Publisher { * @internal */ async runSubscribe(msg: Subscribe, stream: Stream) { - // Serve only what our grant lets us publish, and stop once it no longer does. + // Serve only what our grant lets us publish, and stop once it no longer does. The + // watch is armed before the first check and held to the end, so a shrink while the + // broadcast resolves is never missed. + const revoked = unauthorized(msg.broadcast); + let serving: track.Subscriber | undefined; + const watch = this.#watch(msg.broadcast, () => { + console.debug(`publish revoked: broadcast=${msg.broadcast} track=${msg.track}`); + serving?.close(revoked); + stream.abort(revoked); + }); + try { + await this.#serveSubscribe(msg, stream, watch, (track) => { + serving = track; + }); + } finally { + watch.dispose(); + } + } + + // Watch whether our grant still lets us publish `broadcast`, calling `onRevoke` once + // it does not. Arm it before the request's first check. + #watch(broadcast: Path.Valid, onRevoke: () => void): { revoked: () => boolean; dispose: () => void } { + let revoked = false; + const dispose = + this.#grant?.subscribe(() => { + if (revoked || !this.#denied(broadcast)) return; + revoked = true; + onRevoke(); + }) ?? (() => {}); + return { revoked: () => revoked || this.#denied(broadcast), dispose }; + } + + async #serveSubscribe( + msg: Subscribe, + stream: Stream, + watch: { revoked: () => boolean }, + serving: (track: track.Subscriber) => void, + ) { // Checked before resolving, so a denied request never reaches the origin. - const denied = () => this.#denied(msg.broadcast); - if (denied()) { + if (watch.revoked()) { stream.writer.reset(unauthorized(msg.broadcast)); return; } @@ -586,6 +622,8 @@ export class Publisher { stream.writer.reset(error(err)); return; } + // Revoked while the broadcast resolved: the watch already reset the stream. + if (watch.revoked()) return; if (!front) { console.debug(`publish unknown: broadcast=${msg.broadcast}`); stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`)); @@ -601,6 +639,7 @@ export class Publisher { end: endGroup === undefined ? undefined : { excluded: endGroup }, }, }); + serving(track); positionCursor(track, this.version, msg.startGroup); hooks.replaceGroups(track, { end: endGroup === undefined ? undefined : { excluded: endGroup } }); @@ -610,14 +649,6 @@ export class Publisher { let datagrams = Promise.resolve(); let controls: SubscriptionControls | undefined; - const revoked = unauthorized(msg.broadcast); - const disposeGrant = this.#grant?.subscribe(() => { - if (!denied()) return; - console.debug(`publish revoked: broadcast=${msg.broadcast} track=${track.name}`); - track.close(revoked); - stream.abort(revoked); - }); - try { let timescale: Timescale = Timescale.MILLI; @@ -691,8 +722,6 @@ export class Publisher { track.close(e); stream.abort(e); await Promise.all([datagrams, controls?.decoding]); - } finally { - disposeGrant?.(); } } @@ -706,7 +735,31 @@ export class Publisher { stream.writer.reset(new Error("fetch requires moq-lite-05 or newer")); return; } - if (this.#denied(msg.broadcast)) { + // Like a subscription, the fetch holds its watch until the last frame: a group can stay + // open as long as its track, so a check at accept alone would keep serving after a shrink. + const revoked = unauthorized(msg.broadcast); + let fetched: group.Consumer | undefined; + const watch = this.#watch(msg.broadcast, () => { + console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`); + fetched?.close(revoked); + stream.abort(revoked); + }); + try { + await this.#serveFetch(msg, stream, watch, (group) => { + fetched = group; + }); + } finally { + watch.dispose(); + } + } + + async #serveFetch( + msg: Fetch, + stream: Stream, + watch: { revoked: () => boolean }, + serving: (group: group.Consumer) => void, + ) { + if (watch.revoked()) { stream.writer.reset(unauthorized(msg.broadcast)); return; } @@ -725,6 +778,8 @@ export class Publisher { stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`)); return; } + // Revoked while the broadcast resolved: the watch already reset the stream. + if (watch.revoked()) return; // The subscriber opened this stream, so its send order only ranked the request. Rank the // response here, on the same scale as the group streams it competes with. @@ -736,6 +791,12 @@ export class Publisher { // come off the same front, so the metadata and the frames are one generation. const info = await this.#resolveTrackInfo(front, msg.track); group = await wireOf(front).fetchGroup(msg.track, msg.group, { priority: msg.priority }); + serving(group); + // Revoked while the group resolved: the watch already reset the stream. + if (watch.revoked()) { + group.close(unauthorized(msg.broadcast)); + return; + } await this.#runFetchGroup(group, stream.writer, { timescale: Timescale(info.timescale), start: msg.startFrame, diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index f9dec8b901..53499109c0 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1,5 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; import type { Probe as ProbeStats } from "../connection/stats.ts"; import { error, reason, StreamCode, StreamError } from "../error.ts"; import { HopSchema, isAnonymous, MAX_HOPS, Route, UNKNOWN_HOP } from "../hop.ts"; @@ -803,3 +804,33 @@ test("a fetch started after the subscriber closes rejects without opening a stre expectCut(err, undefined); expect(streams.length).toBe(0); }); + +// The grant watch is armed before the first check, so a shrink while TRACK_INFO is still in +// flight refuses the subscription rather than opening a SUBSCRIBE the grant no longer covers. +test("a grant that shrinks while the subscription sets up refuses it", async () => { + const { quic, streams } = fakeSession(); + const scoped = (prefix: string): Grant => ({ + publish: new Path.Patterns([]), + subscribe: new Path.Patterns([Path.Pattern.subtree(prefix)]), + }); + const grant = new Signal(scoped("room")); + const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n), undefined, undefined, grant); + + const track = subscriber.consume(Path.from("room/cam")).track("video").subscribe().ordered(); + const next = track.nextGroup().catch((err: unknown) => err); + + // Parked on TRACK_INFO when the grant shrinks. + await drainUntil(() => streams.length === 1); + await streams[0].reading; + grant.set(scoped("other")); + await answerTrackInfo(streams[0]); + + const err = await next; + expect(err).toBeInstanceOf(StreamError); + expect((err as StreamError).code).toBe(StreamCode.Unauthorized); + // Nothing past the TRACK stream reached the wire. + expect(streams.length).toBe(1); + + track.close(); + subscriber.close(); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 6e6950e476..db81ef1260 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -80,6 +80,15 @@ function supportsTrackStream(version: Version): boolean { } } +// What a subscription has set up so far, so a revocation (or a timeout) can reach it at +// any stage. +interface SubscribeSetup { + stream?: Stream; + producer?: track.Producer; + // Set once our grant stops covering the broadcast. + revoked?: Error; +} + interface SubscribeEntry { // The write side: incoming GROUP streams are routed here. The application reads // the matching track.Subscriber it got from broadcast.Consumer.subscribe. @@ -531,10 +540,29 @@ export class Subscriber { return; } const refused = unauthorized(broadcast); - if (this.#denied(broadcast)) { - request.reject(refused); - return; + // Armed before the first check and held to the end, so a shrink while the + // subscription sets up is never missed: it resets whatever reached the wire. + const state: SubscribeSetup = {}; + const disposeGrant = this.#grant?.subscribe(() => { + if (state.revoked || !this.#denied(broadcast)) return; + state.revoked = refused; + console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); + state.producer?.close(refused); + state.stream?.abort(refused); + }); + try { + if (this.#denied(broadcast)) { + request.reject(refused); + return; + } + await this.#serveSubscribe(id, broadcast, request, state); + } finally { + disposeGrant?.(); } + } + + async #serveSubscribe(id: bigint, broadcast: Path.Valid, request: track.Request, state: SubscribeSetup) { + const subscription = request.subscription; // `timescale` stays undefined until TRACK_INFO (or, on older drafts, // implicit defaults) resolves it; runGroup blocks on it before decoding. @@ -555,7 +583,6 @@ export class Subscriber { // Open the stream under a timeout. The stream handle flows back via `state` // so the timeout path can abort it if it finishes opening after the deadline. - const state: { stream?: Stream } = {}; const setup = this.#openSubscribe(state, msg, request, id, timescale); let opened: { stream: Stream; entry: SubscribeEntry }; @@ -568,8 +595,10 @@ export class Subscriber { console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { // The setup outlived its deadline waiting for the first response: a control - // timeout, not content that arrived late. - const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err); + // timeout, not content that arrived late. A revocation says so instead. + const e = + state.revoked ?? + (err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err)); request.reject(e); this.#subscribes.delete(id); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); @@ -585,13 +614,6 @@ export class Subscriber { const { stream, entry } = opened; const producer = entry.track; - // Losing the grant ends the subscription, leaving the session alone. - const disposeGrant = this.#grant?.subscribe(() => { - if (!this.#denied(broadcast)) return; - console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); - producer.close(refused); - stream.abort(refused); - }); try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there @@ -639,7 +661,6 @@ export class Subscriber { console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); } finally { - disposeGrant?.(); this.#subscribes.delete(id); } } @@ -654,7 +675,7 @@ export class Subscriber { // SUBSCRIBE is accepted implicitly (no SUBSCRIBE_OK). Older drafts carry no // per-track properties, so they resolve to defaults and just drain SUBSCRIBE_OK. async #openSubscribe( - state: { stream?: Stream }, + state: SubscribeSetup, msg: Subscribe, request: track.Request, id: bigint, @@ -666,6 +687,7 @@ export class Subscriber { if (supportsTrackStream(this.version)) { // Fetch the immutable properties once via the TRACK stream. const info = await this.#trackInfo(msg.broadcast, msg.track); + if (state.revoked) throw state.revoked; producer = request.accept(this.#toModelInfo(info)); timescale.set(info.timescale); } else { @@ -678,8 +700,14 @@ export class Subscriber { // Register before opening SUBSCRIBE so a racing GROUP stream finds the entry. const entry: SubscribeEntry = { track: producer, timescale, tail: new Tail() }; this.#subscribes.set(id, entry); + state.producer = producer; state.stream = await Stream.open(this.#quic); + // Revoked while the stream opened: nothing reached the wire yet, so stop here. + if (state.revoked) { + state.stream.abort(state.revoked); + throw state.revoked; + } await state.stream.writer.u53(StreamId.Subscribe); await msg.encode(state.stream.writer, this.version); From 47abdbb22e37354441411b28bfe6d775ffa42300 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:15:01 -0700 Subject: [PATCH 05/11] quest(auth/narrowing): done; bench the narrowing and its gates Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 8 +- quest/m1/auth/error-codes.md | 4 - quest/m1/auth/narrowing.md | 65 ------------- quest/m1/auth/relay-refresh.md | 8 +- quest/m1/auth/request-token.md | 2 +- rs/moq-net/benches/origin.rs | 161 ++++++++++++++++++++++++++++++++- 6 files changed, 165 insertions(+), 83 deletions(-) delete mode 100644 quest/m1/auth/narrowing.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index b930917037..7651c01de2 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,9 +39,8 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - [Origin narrowing](/quest/m1/auth/narrowing.md) owns the common resize - operation; relay token handling requires it rather than shipping a temporary - close-on-shrink policy. + `auth::Handle::narrow` is the common resize operation; relay token handling + uses it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side root, and every token in a union shares the connection's root. Unscoped @@ -91,9 +90,6 @@ existing lite-06 ALPN. - [AUTH_OK preflight](/quest/m1/auth/auth-ok-preflight.md) - an unencodable IETF grant answers NOT_SUPPORTED with nothing written, as JS already does - [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends -- [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in - place: subscriptions outside it reset, publishes outside it abort, and relay - revalidation stops closing the session - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION diff --git a/quest/m1/auth/error-codes.md b/quest/m1/auth/error-codes.md index 71881c1f51..b1bd9e518d 100644 --- a/quest/m1/auth/error-codes.md +++ b/quest/m1/auth/error-codes.md @@ -30,8 +30,4 @@ are closed, so every way an AUTH stream ends reports what actually happened: it. - One regression test per item, each failing without its fix. -The fifth deferred item, a grant recheck after async origin resolution, -belongs to [Origin narrowing](/quest/m1/auth/narrowing.md) with the other -watcher races. - Public API: none. Wire: none. diff --git a/quest/m1/auth/narrowing.md b/quest/m1/auth/narrowing.md deleted file mode 100644 index 2771cec520..0000000000 --- a/quest/m1/auth/narrowing.md +++ /dev/null @@ -1,65 +0,0 @@ -# [M] A live origin grant narrows in place - -## Goal - -A `moq-net` session's grant can be replaced by a narrower pattern union -while the session is live: announce cursors retract the patterns that fell -outside, resolution refuses them, subscriptions under them reset with -`UNAUTHORIZED`, and broadcasts the session published under them abort. The -relay uses it on revalidation so a moderation decision (a deafened user's -audio path) takes effect on the session it targets, which a forked client -cannot bypass. This is the per-subscriber exclusion -[#2714](https://github.com/moq-dev/moq/issues/2714) asked for; the scope and -match half already landed in #3672 and #3746. - -A narrowing always succeeds. A changed root still closes the session. - -## Plan - -- Enforce per session, not per handout. The line's session gate for received - grants (`auth::Gate`, the lite publisher resetting subscriptions it denies, - `regrant` retracting announces no longer covered, and the IETF equivalent) - is the same mechanism pointed at the relay's own grant. Reuse it rather than - building a second one. Readers that bypass the session publisher (HTTP - `/fetch` holds its own lease and closes on it; moq-hls and WHEP enforce no - per-user grant today) are out of scope. -- #3971 built narrowing alone and was closed: it refused whenever anything - live sat in the removed part, which is exactly when a grant narrows, and it - kept the ceiling in a side map. Scopes today are copied by value into every - handle and cursor (`OriginScope` in `rs/moq-net/src/model/origin.rs`), so - narrowing needs shared state. If handles need a tree to share it, keep the - ceiling on the grant node itself (an `Arc` shared by clones and children). -- Close the revocation races Codex found on - [#4179](https://github.com/moq-dev/moq/pull/4179), which the same watcher - mechanism owns: an in-flight lite FETCH keeps serving after its path is - revoked (Rust and JS check only at accept, and Rust's dropped fetches reset - `CANCELLED`, not `UNAUTHORIZED`); a Rust lite subscription still in - `Establish` when the grant shrinks is dropped with `CANCELLED` instead of - aborted `UNAUTHORIZED`; and the JS lite publisher and subscriber arm their - grant watchers only after an await (`demand()`, `#openSubscribe`), and - `Getter.subscribe` does not replay, so a shrink during setup is missed for - good. Every request holds one watcher from its first check to its end, and - rechecks when the watcher is armed. -- Publish side: routes and broadcasts the session published outside the - narrowed grant abort, so consumers see `Unauthorized` just as the subscribe - side does. Draining is not an option: a group may stay open as long as its - track. -- Relay revalidation uses it: a re-checked grant with the same root narrows - the session instead of closing it (`Lease::ended` reporting "grant - narrowed" in `rs/moq-relay/src/auth.rs`), on every accept path: native, - io_uring, and `moq --listen` share `connection::supervise`, while WebSocket - has its own copy of the loop. -- Prove the deafen case end to end: subscribe under a room prefix, narrow - with a grant that excludes that audio path, and assert the subscription - resets with `UNAUTHORIZED` and no further objects arrive, while a sibling - path under the same prefix keeps flowing. -- Benchmark the narrowing and per-session gate cost in - `rs/moq-net/benches/origin.rs`, swept over routes and subscribers, with a - session that never narrows as the baseline. - -Public API: additive on moq-net and on moq-relay's revalidate path. Wire: -none; narrowed subscriptions reset with the existing UNAUTHORIZED code. - -## Closes - -- [#2714](https://github.com/moq-dev/moq/issues/2714) - close this issue when the quest finishes diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 4d03299fb2..677da06b6f 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -36,7 +36,9 @@ expiry, an expiry that leaves the union intact ends only that token, and publish-only session never starts receiving announcements because a later token happened to carry subscribe prefixes, and swapped in whenever the set grows; keep it behind one function, and a shrinking union goes through - [Origin narrowing](/quest/m1/auth/narrowing.md) rather than a close. + `auth::Handle::narrow` rather than a close. `narrow` only ever narrows, so + a union that grows back after a shrink also needs the session's ceiling + replaced, not just new origin handles. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session @@ -79,7 +81,3 @@ expiry, an expiry that leaves the union intact ends only that token, and proxy-mode outage on re-check keeps the token. Additive. - -## Required - -- [Origin narrowing](/quest/m1/auth/narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index fa93750186..b9635432fd 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - [Origin narrowing](/quest/m1/auth/narrowing.md). + `auth::Handle::narrow`. - Relay: each such request gets its own lease from a per-request call on `moq_auth::Client`, not the `Client::attach` that [Relay tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 62692ebd9d..0b0e4193c4 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -8,13 +8,22 @@ //! down to its prefix and beneath it, and each recomputes its best route from //! the entries at that prefix alone. //! +//! `origin/narrow*` put a session in front of the origin, since a narrowing is +//! enforced by the session serving it: every subscription holds a gate on the +//! session's grant, and a narrowing wakes each one. The baseline is a session +//! that never narrows. +//! //! Run with `cargo bench -p moq-net --bench origin`. +#[path = "../tests/support/mod.rs"] +mod support; + use std::time::Duration; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use futures::FutureExt; -use moq_net::{Hop, Hops, Pattern, Patterns, Timestamp, announce, broadcast, kio, origin}; +use moq_net::{Hop, Hops, Pattern, Patterns, Timestamp, announce, auth, broadcast, kio, origin, track}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// `(publishers, subscribers)` shapes for the fan-out benchmarks. const SHAPES: [(usize, usize); 3] = [(100, 10), (1_000, 100), (1_000, 1_000)]; @@ -423,6 +432,152 @@ fn bench_handoff(c: &mut Criterion) { group.finish(); } +/// `(routes, subscribers)` shapes for the narrowing benchmarks: [`SHAPES`] plus a +/// row that grows the routes alone, so each axis shows its own slope. +const NARROW_SHAPES: [(usize, usize); 4] = [(100, 10), (1_000, 10), (1_000, 100), (1_000, 1_000)]; + +/// A relay serving `routes` broadcasts to one client session subscribed to +/// `subscribers` of them. The first subscription is the one a narrowing to +/// `room/**` ends: its broadcast sits at `muted` instead. +struct Watching { + pair: MockPair, + tracks: Vec, + subscriptions: Vec, + _broadcasts: Vec, + _origins: [origin::Producer; 2], +} + +fn watched_path(index: usize) -> String { + match index { + 0 => "muted".to_string(), + index => format!("room/{index}"), + } +} + +async fn watching(routes: usize, subscribers: usize) -> Watching { + let spawn = |hop| { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer + }; + let relay = spawn(1); + let received = spawn(2); + + let mut broadcasts = Vec::new(); + let mut tracks = Vec::new(); + for index in 0..routes { + let broadcast = relay.publish(watched_path(index), origin::Route::default()).unwrap(); + tracks.push(broadcast.create_track("video", None).unwrap()); + broadcasts.push(broadcast); + } + + let mut options = MockConnectOptions::new("moq-lite-06".parse().unwrap()); + options.server_publish = Some(relay.consume()); + options.client_subscribe = Some(received.clone()); + let pair = connect_mock(options).await; + + let mut subscriptions = Vec::new(); + for index in 0..subscribers { + let remote = received.consume().routed_broadcast(watched_path(index)).await.unwrap(); + let subscription = remote.track("video").unwrap().subscribe(None).await.unwrap(); + subscriptions.push(subscription); + } + // One round, so every subscription is live end to end before anything is timed. + let mut watching = Watching { + pair, + tracks, + subscriptions, + _broadcasts: broadcasts, + _origins: [relay, received], + }; + watching.round().await; + watching +} + +impl Watching { + /// Every subscribed track writes a group, and every subscription reads it. + async fn round(&mut self) { + for track in &self.tracks[..self.subscriptions.len()] { + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, b"frame".as_ref()).unwrap(); + group.finish().unwrap(); + } + for subscription in &mut self.subscriptions { + subscription.recv_group().await.unwrap().expect("track ended"); + } + } +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap() +} + +/// Everything under `room/`, which leaves out only the `muted` subscription. +fn room() -> auth::Grant { + auth::Grant { + subscribe: Pattern::subtree("room").unwrap().into(), + ..auth::Grant::all() + } +} + +/// Steady-state delivery through a session's gates: one round across every +/// subscription, on a session that never narrows and on one narrowed to a grant +/// that still covers them all. Neither may depend on `routes`. +fn bench_narrow_delivery(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/narrow_delivery"); + let rt = runtime(); + for (routes, subscribers) in NARROW_SHAPES { + group.throughput(Throughput::Elements(subscribers as u64)); + for narrowed in [false, true] { + let label = if narrowed { "narrowed" } else { "never" }; + let id = BenchmarkId::new(label, format!("{routes}r_{subscribers}s")); + group.bench_function(id, |b| { + let mut watching = rt.block_on(watching(routes, subscribers)); + if narrowed { + // Covers every subscription but the muted one, which is not subscribed here. + let mut grant = room(); + grant.subscribe.insert(Pattern::literal("muted").unwrap()); + watching.pair.server.auth().narrow(&grant); + } + b.iter(|| rt.block_on(watching.round())); + }); + } + } + group.finish(); +} + +/// One narrowing on a live session: from `narrow` until the subscription it +/// excludes resets. It wakes every subscription's gate and re-checks every route +/// the session announced, so it may grow with the session's own `subscribers` +/// and `routes`, never with anything else on the origin. +fn bench_narrow(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/narrow"); + group.sample_size(10); + group.sampling_mode(criterion::SamplingMode::Flat); + let rt = runtime(); + for (routes, subscribers) in NARROW_SHAPES { + let id = BenchmarkId::from_parameter(format!("{routes}r_{subscribers}s")); + group.bench_function(id, |b| { + b.iter_custom(|iters| { + let mut elapsed = Duration::ZERO; + for _ in 0..iters { + let mut watching = rt.block_on(watching(routes, subscribers)); + let start = std::time::Instant::now(); + watching.pair.server.auth().narrow(&room()); + let ended = rt.block_on(watching.subscriptions[0].recv_group()); + elapsed += start.elapsed(); + assert!(ended.is_err(), "the muted subscription outlived the narrowing"); + } + elapsed + }); + }); + } + group.finish(); +} + criterion_group!( benches, bench_announce, @@ -433,6 +588,8 @@ criterion_group!( bench_serve_idle, bench_subscribe, bench_request, - bench_handoff + bench_handoff, + bench_narrow_delivery, + bench_narrow ); criterion_main!(benches); From 196bb9aad76e775c7b86673fe14f81d68383d781 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:20:39 -0700 Subject: [PATCH 06/11] chore: fmt and doc touch-ups Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 4 +++- rs/moq-net/src/ietf/publisher.rs | 4 +--- rs/moq-net/src/ietf/subscriber.rs | 8 ++++++-- rs/moq-net/src/lite/publisher.rs | 3 ++- rs/moq-net/src/lite/subscriber.rs | 11 +++++++++-- rs/moq-net/tests/auth.rs | 5 +---- rs/moq-relay/src/auth.rs | 6 +++++- rs/moq-relay/tests/auth_lifetime.rs | 14 +++++++++++--- 8 files changed, 38 insertions(+), 17 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 2833c5b703..214c5818b3 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -504,7 +504,9 @@ impl Handle { /// Whether the ceiling alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. pub(crate) fn within_ceiling(&self, direction: Direction, path: &str) -> bool { - self.state.read().parts(direction).1.is_none_or(|ceiling| ceiling.matches(path)) + let state = self.state.read(); + let (_, ceiling) = state.parts(direction); + ceiling.is_none_or(|ceiling| ceiling.matches(path)) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 9a28ed8f54..7dbe052595 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1930,9 +1930,7 @@ where } // A grant change applies before the next update, so a namespace it no // longer covers is withdrawn rather than re-sent. - if let Poll::Ready(permit) = - self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) - { + if let Poll::Ready(permit) = self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) { return Poll::Ready(NamespaceEvent::Regrant(permit)); } if let Poll::Ready(update) = announced.poll_next(waiter) { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e826192b28..071090d6ae 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1379,7 +1379,10 @@ where } Entry::Vacant(entry) => { // Nothing the session no longer lets the peer publish is accepted. - if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + if !self + .auth + .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) + { return Err(Error::Unauthorized); } // Propagates Error::Unauthorized if the namespace is out of scope. @@ -1470,7 +1473,8 @@ where // own gates, with `Unauthorized`. let mut narrowed = false; while let Poll::Ready(permit) = - self.auth.poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + self.auth + .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) { narrowed |= !permit.within_ceiling(path.as_str()); } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index b7e3ed7198..0c9605999f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1111,7 +1111,8 @@ impl AnnounceRun { // advertises something it would withdraw, or abort over, a moment later. if let Some(auth) = &self.auth { ready!(auth.poll_setup_answered(waiter)); - if let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) { + if let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) + { self.permit = permit; } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 2cb602d0a7..cbe26042f4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -339,7 +339,10 @@ impl Subscriber { /// this session on demand. `None` when we may not accept it: outside our origin's /// scope, or outside what the session still lets the peer publish. fn serve_route(&self, path: &Path, route: crate::origin::Route) -> Option { - if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + if !self + .auth + .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) + { tracing::debug!(route = %self.log_path(path), "declining announce outside the narrowed grant"); return None; } @@ -1711,7 +1714,11 @@ mod tests { let broadcast = crate::broadcast::Info::new().produce(); let request = broadcast.reserve_track("audio").unwrap(); let mut serving = ServeLoop::new(&serve, request, Default::default(), Some(Timescale::default())); - let establish = serve.prepare_establish(&mut serving.serving, Subscription::default(), Some(Timescale::default())); + let establish = serve.prepare_establish( + &mut serving.serving, + Subscription::default(), + Some(Timescale::default()), + ); serving.mode = ServeMode::Establish(establish); let gate_run = serve.gate(); let mut running = TrackServeRun { diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 458e68e33a..4f2a1aaeef 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -992,10 +992,7 @@ macro_rules! narrow_cases { }; } -narrow_cases!( - a_narrowing_deafens_one_path, - a_narrowing_aborts_what_the_peer_published -); +narrow_cases!(a_narrowing_deafens_one_path, a_narrowing_aborts_what_the_peer_published); #[tokio::test] async fn lite_05_narrowing_resets_a_fetch_in_flight() { diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 09864a8292..266384d407 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -561,7 +561,11 @@ impl Lease { /// Patterns as one comma-separated line, for logs. fn join(patterns: &Patterns) -> String { - patterns.iter().map(|pattern| pattern.as_str()).collect::>().join(",") + patterns + .iter() + .map(|pattern| pattern.as_str()) + .collect::>() + .join(",") } enum Decider { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 16c3c0049d..0412d69b6f 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -4,8 +4,9 @@ //! or QUIC) or its axum WebSocket path (`serve_ws` over `ws://`), points it at a //! scripted auth server, connects a publisher and a subscriber, confirms media //! flows, then asserts the relay follows the server's word: a re-check that moves -//! the tier retags the live session's stats, a narrower grant or a refusal closes it, an outage -//! keeps it until `expires`, and every close reports `end` with what it moved. +//! the tier retags the live session's stats, a narrower grant narrows it in place, a +//! moved root or a refusal closes it, an outage keeps it until `expires`, and every +//! close reports `end` with what it moved. //! The last tests swap the server for an in-process decider answering //! `Admissions`, and prove the lease it drives reaches the session the same way. @@ -609,7 +610,14 @@ async fn a_narrower_grant_narrows_live_sessions() { .await .expect("announcement timeout") .expect("announced broadcast resolves"); - subs.push(broadcast.track("media").unwrap().subscribe(None).await.expect("subscribe")); + subs.push( + broadcast + .track("media") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"), + ); } let send = |index: usize, sequence: u64| { let mut group = tracks[index].1.append_group().expect("append group"); From 7e773ee4e38b68003030deed7769420091392e20 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:52:37 -0700 Subject: [PATCH 07/11] test(relay): iterate subscriptions directly Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/tests/auth_lifetime.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 0412d69b6f..3cf3fb0438 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -627,9 +627,9 @@ async fn a_narrower_grant_narrows_live_sessions() { group.finish().expect("finish group"); assert_eq!(group.sequence, sequence); }; - for index in 0..2 { + for (index, sub) in subs.iter_mut().enumerate() { send(index, 0); - tokio::time::timeout(TIMEOUT, subs[index].recv_group()) + tokio::time::timeout(TIMEOUT, sub.recv_group()) .await .expect("recv_group timeout") .expect("recv_group failed") From 4da76543b728887d25707cd1cadc1e42f353458f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 17:50:29 -0700 Subject: [PATCH 08/11] feat(net): replace narrow with a symmetric auth::Handle::limit A limit replaces the last one, narrower or wider: widening re-announces what becomes visible, re-attaches the peer's withheld routes, and accepts requests again. Relay revalidation sets the limit to whatever the re-check returns. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 13 +-- doc/lib/rs/moq-net.md | 21 ++-- quest/m1/auth/README.md | 2 +- quest/m1/auth/relay-refresh.md | 27 +++-- quest/m1/auth/request-token.md | 2 +- rs/moq-net/benches/origin.rs | 4 +- rs/moq-net/src/auth.rs | 162 ++++++++++++++-------------- rs/moq-net/src/client.rs | 2 +- rs/moq-net/src/ietf/auth.rs | 2 +- rs/moq-net/src/ietf/session.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 104 ++++++++++++++---- rs/moq-net/src/lite/publisher.rs | 2 +- rs/moq-net/src/lite/subscriber.rs | 142 +++++++++++++++--------- rs/moq-net/tests/auth.rs | 151 +++++++++++++++++++++++--- rs/moq-relay/src/auth.rs | 63 ++++++----- rs/moq-relay/src/connection.rs | 6 +- rs/moq-relay/src/websocket.rs | 2 +- rs/moq-relay/tests/auth_lifetime.rs | 32 ++++-- 18 files changed, 492 insertions(+), 247 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 4bf255d303..45917d6a45 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -64,12 +64,13 @@ The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the -same request. A grant applies: one that no longer covers what the session holds -narrows the live session in place to what both grants allow, so a moderation -decision lands on the session it targets. Its subscriptions and fetches outside -the new grant reset with `Unauthorized`, the broadcasts it published outside -abort, and everything else keeps flowing; a wider part waits for a reconnect. A -one-shot HTTP `/fetch` ends instead. A +same request. A grant applies: its patterns resize the live session in place, +narrower or wider, so a moderation decision lands on the session it targets and +can be lifted the same way. Subscriptions and fetches outside a narrower grant +reset with `Unauthorized`, the broadcasts it published outside abort, and +everything else keeps flowing; a wider grant brings those paths back, up to what +the session was admitted with. A one-shot HTTP `/fetch` ends on a narrower grant +instead. A changed `root` or `mounts` closes the session with `Unauthorized`, and so does a flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 2b4b3bbe06..4e4c571b24 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -104,22 +104,27 @@ shrinks withdraws the announcements and cancels the subscriptions it no longer covers, and leaves the session up. On moq-lite each cancelled stream resets with `StreamError::Unauthorized`. -`session.auth().narrow(&grant)` takes access away from the peer on a live session, -on every version, whether or not the peer speaks AUTH: the session enforces it -itself. It only ever narrows, keeping what both the current ceiling and `grant` -allow. What falls outside ends at once: announcements to the peer retract, its -new requests are refused, its subscriptions and fetches reset with +`session.auth().limit(&grant)` sets the most the peer may do on a live session, +replacing any earlier limit, on every version, whether or not the peer speaks +AUTH: the session enforces it itself, and never past its origin handles. A +narrower limit ends what falls outside at once: announcements to the peer +retract, its new requests are refused, its subscriptions and fetches reset with `Unauthorized`, and the broadcasts it published abort, so local readers see -`Unauthorized` too. The rest of the session carries on. When the session answers -the peer's connection credential itself, the peer is sent the narrowed grant. +`Unauthorized` too. A wider one brings back what the old limit held back: +announcements are made again, the peer's own announcements return to the origin, +and new requests are accepted. The rest of the session carries on. When the +session answers the peer's connection credential itself, the peer is sent its +grant within the limit. ```rust // Deafen alice's audio: the peer may still subscribe to her video. let video: Pattern = "room/alice/video/**".parse()?; -session.auth().narrow(&auth::Grant { +session.auth().limit(&auth::Grant { subscribe: video.into(), ..auth::Grant::all() }); +// Undeafen. +session.auth().limit(&auth::Grant::all()); ``` ## Patterns diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 56a7bc337f..f22d0f3353 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,7 +39,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - `auth::Handle::narrow` is the common resize operation; relay token handling + `auth::Handle::limit` is the common resize operation; relay token handling uses it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 677da06b6f..00813f65fc 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -30,29 +30,28 @@ expiry, an expiry that leaves the union intact ends only that token, and sessions reject a non-empty token as `Unsupported`. - Union: the connection holds the set of accepted leases. Every one must carry the admitted root, else `AUTH_ERROR { Unauthorized }` naming the - root. The session's origin handles are rebuilt through `Cluster::publisher` - and `Cluster::subscriber` from the union of the set's publish and subscribe - prefixes, intersected with the role the client declared at SETUP so a + root. The session's limit is the union of the set's publish and subscribe + patterns, intersected with the role the client declared at SETUP so a publish-only session never starts receiving announcements because a later - token happened to carry subscribe prefixes, and swapped in whenever the set - grows; keep it behind one function, and a shrinking union goes through - `auth::Handle::narrow` rather than a close. `narrow` only ever narrows, so - a union that grows back after a shrink also needs the session's ceiling - replaced, not just new origin handles. + token happened to carry subscribe prefixes, and set through + `auth::Handle::limit` whenever the union changes, grown or shrunk; keep it + behind one function. A limit never reaches past the session's origin handles, + so scope those to the admitted root and role rather than to the first + token's patterns, and nothing is rebuilt or swapped in. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session - continues, shrunk narrows the origin and resets each stream that lost - access with `UNAUTHORIZED`. A token + continues, shrunk sets the smaller limit, which resets each stream that + lost access with `UNAUTHORIZED`. A token withdrawn by the client (its stream closed or reset) recomputes the union the same way with nothing written back, since the stream is gone: unchanged - means the session continues, shrunk narrows the same way. Each lease revalidates on + means the session continues, shrunk sets the limit the same way. Each lease revalidates on its own cadence, and every re-check that changes the grant, a lower `expires` or a different `publish` or `subscribe`, writes the complete replacement grant as an update AUTH_OK on that token's stream and recomputes the union from the latest grant of every token, so the client can present a replacement in time and a narrowed token never keeps its old - scope; a shrunk union narrows as above. + scope; a changed union sets the limit as above. - Refusals map as connect-time ones do and never expose a status: every refusal `Client::attach` returns for a new token, a `403`, an empty or invalid grant, an unparseable body, or a `5xx`, is @@ -74,8 +73,8 @@ expiry, an expiry that leaves the union intact ends only that token, and - Tests: a second token with a later `exp` and the same scope keeps the session past the first token's expiry, with only the first stream ending; an expired or invalid token is refused and nothing changes; a token with a - different root is refused naming it; a token adding a prefix rebuilds the - origin scope and the prefix is now solicited; withdrawing the only token + different root is refused naming it; a token adding a prefix widens the + limit and the prefix is now solicited; withdrawing the only token covering a prefix resets that prefix's streams with `UNAUTHORIZED` and the session stays connected; an mTLS session refuses a token; a proxy-mode outage on re-check keeps the token. diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index b9635432fd..f31f9094b9 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - `auth::Handle::narrow`. + `auth::Handle::limit`. - Relay: each such request gets its own lease from a per-request call on `moq_auth::Client`, not the `Client::attach` that [Relay tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 0b0e4193c4..d1d426ee69 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -540,7 +540,7 @@ fn bench_narrow_delivery(c: &mut Criterion) { // Covers every subscription but the muted one, which is not subscribed here. let mut grant = room(); grant.subscribe.insert(Pattern::literal("muted").unwrap()); - watching.pair.server.auth().narrow(&grant); + watching.pair.server.auth().limit(&grant); } b.iter(|| rt.block_on(watching.round())); }); @@ -566,7 +566,7 @@ fn bench_narrow(c: &mut Criterion) { for _ in 0..iters { let mut watching = rt.block_on(watching(routes, subscribers)); let start = std::time::Instant::now(); - watching.pair.server.auth().narrow(&room()); + watching.pair.server.auth().limit(&room()); let ended = rt.block_on(watching.subscriptions[0].recv_group()); elapsed += start.elapsed(); assert!(ended.is_err(), "the muted subscription outlived the narrowing"); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 214c5818b3..a8f19e4cd0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -11,8 +11,8 @@ //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's //! driver, and then answers every token the peer presents. Either way, -//! [`narrow`](Handle::narrow) takes access away from the peer on a live session, -//! on every version, whether or not it speaks AUTH. +//! [`limit`](Handle::limit) resizes what the peer may do on a live session, on +//! every version, whether or not it speaks AUTH. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -114,10 +114,10 @@ pub(crate) struct State { union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// The most this side lets the peer do, in the peer's terms: `None` until - /// [`Handle::narrow`], then only ever narrower. - ceiling: Option, - /// Bumped whenever the union or the ceiling changes, so the session's per-stream + /// The most this side lets the peer do, in the peer's terms: `None` (only the + /// origin handles bound it) until [`Handle::limit`] sets it. + limit: Option, + /// Bumped whenever the union or the limit changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. epoch: u64, acceptor: Acceptor, @@ -171,58 +171,58 @@ impl State { self.closed.clone() } - /// The union's and the ceiling's patterns for `direction`, each `None` while + /// The union's and the limit's patterns for `direction`, each `None` while /// unrestricted. fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) { - // The peer's grant names what we may do; the ceiling names what the peer may, + // The peer's grant names what we may do; the limit names what the peer may, // so what we send is what the peer may subscribe to, and the other way around. match direction { Direction::Publish => ( self.union.as_ref().map(|union| &union.publish), - self.ceiling.as_ref().map(|ceiling| &ceiling.subscribe), + self.limit.as_ref().map(|limit| &limit.subscribe), ), Direction::Subscribe => ( self.union.as_ref().map(|union| &union.subscribe), - self.ceiling.as_ref().map(|ceiling| &ceiling.publish), + self.limit.as_ref().map(|limit| &limit.publish), ), } } /// What `direction` allows right now. fn permit(&self, direction: Direction) -> Permit { - let (granted, ceiling) = self.parts(direction); + let (granted, limit) = self.parts(direction); Permit { granted: granted.cloned(), - ceiling: ceiling.cloned(), + limit: limit.cloned(), } } /// Whether `direction` allows `path` right now, without copying the patterns. fn allows(&self, direction: Direction, path: &str) -> bool { - let (granted, ceiling) = self.parts(direction); - granted.is_none_or(|granted| granted.matches(path)) && ceiling.is_none_or(|ceiling| ceiling.matches(path)) + let (granted, limit) = self.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) && limit.is_none_or(|limit| limit.matches(path)) } } /// What one direction of a session allows: the grant the peer gave this side (the -/// union of its tokens) and the ceiling this side narrowed the peer to, each `None` +/// union of its tokens) and the limit this side set on the peer, each `None` /// while unrestricted. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct Permit { granted: Option, - ceiling: Option, + limit: Option, } impl Permit { /// Whether both allow `path`. pub(crate) fn matches(&self, path: &str) -> bool { - self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_ceiling(path) + self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_limit(path) } - /// Whether the ceiling alone allows `path`: for what the peer offers us, which our + /// Whether the limit alone allows `path`: for what the peer offers us, which our /// own grant does not decide. - pub(crate) fn within_ceiling(&self, path: &str) -> bool { - self.ceiling.as_ref().is_none_or(|ceiling| ceiling.matches(path)) + pub(crate) fn within_limit(&self, path: &str) -> bool { + self.limit.as_ref().is_none_or(|limit| limit.matches(path)) } /// Whether this allows everything `other` does, judged part by part, so a `false` @@ -233,7 +233,7 @@ impl Permit { (Some(_), None) => false, (Some(this), Some(other)) => this.covers(other), }; - covers(&self.granted, &other.granted) && covers(&self.ceiling, &other.ceiling) + covers(&self.granted, &other.granted) && covers(&self.limit, &other.limit) } } @@ -439,27 +439,25 @@ impl Handle { Poll::Ready(()) } - /// Narrow what the peer may do on this session to at most `grant`, in the - /// session's own paths. + /// Set the most the peer may do on this session to `grant`, in the session's own + /// paths, replacing any earlier limit. The session's origin handles still bound it, + /// so a limit never grants more than they allow. /// - /// A narrowing only ever takes away: the session keeps what both its current - /// ceiling and `grant` allow, so a wider grant changes nothing. What falls outside - /// ends at once, with [`Error::Unauthorized`] where it has a reader: announcements - /// to the peer retract, its new requests are refused, its subscriptions reset, and - /// the broadcasts it published abort. The rest of the session carries on. It works - /// on every version, since the session enforces it without the peer's help. + /// A narrower limit ends what falls outside at once, with [`Error::Unauthorized`] + /// where it has a reader: announcements to the peer retract, its new requests are + /// refused, its subscriptions reset, and the broadcasts it published abort. A wider + /// one brings back what the old limit held back: announcements to the peer are made + /// again, and so are the peer's announcements it withheld from the origin, and new + /// requests are accepted. The rest of the session carries on. It works on every + /// version, since the session enforces it without the peer's help. /// /// When the session answers the peer's connection credential itself, the peer is - /// told the narrowed grant, `expires` included. An application answering tokens - /// through [`requests`](Self::requests) updates its own [`Issued`] grants. - pub fn narrow(&self, grant: &Grant) { + /// told its grant within the limit, `expires` included. An application answering + /// tokens through [`requests`](Self::requests) updates its own [`Issued`] grants. + pub fn limit(&self, grant: &Grant) { let mut state = self.state.lock(); - let ceiling = match &state.ceiling { - Some(ceiling) => ceiling.intersect(grant), - None => grant.clone(), - }; - if state.ceiling.as_ref() != Some(&ceiling) { - state.ceiling = Some(ceiling); + if state.limit.as_ref() != Some(grant) { + state.limit = Some(grant.clone()); state.epoch += 1; } } @@ -480,11 +478,11 @@ impl Handle { Poll::Ready(state.permit(direction)) } - /// The ceiling once it may have changed since `epoch` last saw it, advancing `epoch`. - pub(crate) fn poll_ceiling(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { + /// The limit once it may have changed since `epoch` last saw it, advancing `epoch`. + pub(crate) fn poll_limit(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); *epoch = state.epoch; - Poll::Ready(state.ceiling.clone()) + Poll::Ready(state.limit.clone()) } fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { @@ -496,17 +494,17 @@ impl Handle { /// Whether `direction` allows `path` right now: the union (allowing everything /// while it is `None`, before an answer or on a version without AUTH) and the - /// ceiling both. + /// limit both. pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { self.state.read().allows(direction, path) } - /// Whether the ceiling alone allows `direction` at `path`: for a route the peer + /// Whether the limit alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. - pub(crate) fn within_ceiling(&self, direction: Direction, path: &str) -> bool { + pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { let state = self.state.read(); - let (_, ceiling) = state.parts(direction); - ceiling.is_none_or(|ceiling| ceiling.matches(path)) + let (_, limit) = state.parts(direction); + limit.is_none_or(|limit| limit.matches(path)) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and @@ -843,9 +841,9 @@ impl Drop for Issued { /// Which way media flows, from this side's view. #[derive(Clone, Copy)] pub(crate) enum Direction { - /// This side sends to the peer: our grant's `publish`, and the ceiling's `subscribe`. + /// This side sends to the peer: our grant's `publish`, and the limit's `subscribe`. Publish, - /// The peer sends to this side: our grant's `subscribe`, and the ceiling's `publish`. + /// The peer sends to this side: our grant's `subscribe`, and the limit's `publish`. Subscribe, } @@ -854,7 +852,7 @@ pub(crate) enum Direction { /// /// Create it at the request's first check and hold it to the end, so a change /// landing in between is never missed. Cheap to poll on every wakeup: the path is -/// only re-matched when the union or the ceiling changes. +/// only re-matched when the union or the limit changes. pub(crate) struct Gate { handle: Handle, path: crate::PathOwned, @@ -873,7 +871,7 @@ impl Gate { } /// Resolve once the session no longer allows the path. A union that is still - /// `None` (no answer yet, or a version without AUTH) and a session never narrowed + /// `None` (no answer yet, or a version without AUTH) and a session never limited /// allow everything. pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { loop { @@ -886,35 +884,35 @@ impl Gate { } /// The default acceptor's answer to the peer's connection credential: the grant -/// the session's origin handles allow, narrowed by the ceiling as it changes. +/// the session's origin handles allow, within the limit as it changes. pub(crate) struct DefaultGrant { handle: Handle, base: Grant, epoch: u64, - ceiling: Option, + limit: Option, /// The grant last handed out, so an unrelated wakeup sends nothing. sent: Option, } impl DefaultGrant { pub(crate) fn new(handle: Handle, base: Grant) -> Self { - let ceiling = handle.state.read().ceiling.clone(); + let limit = handle.state.read().limit.clone(); Self { handle, base, epoch: 0, - ceiling, + limit, sent: None, } } - /// The grant to send next: the first, then each one a narrowing changes. + /// The grant to send next: the first, then each one a new limit changes. pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll { - while let Poll::Ready(ceiling) = self.handle.poll_ceiling(&mut self.epoch, waiter) { - self.ceiling = ceiling; + while let Poll::Ready(limit) = self.handle.poll_limit(&mut self.epoch, waiter) { + self.limit = limit; } - let grant = match &self.ceiling { - Some(ceiling) => self.base.intersect(ceiling), + let grant = match &self.limit { + Some(limit) => self.base.intersect(limit), None => self.base.clone(), }; if self.sent.as_ref() == Some(&grant) { @@ -1009,49 +1007,49 @@ mod tests { } } - /// A narrowing keeps only what every narrowing so far allows, so a wider one - /// changes nothing, and it applies to a version without AUTH too. + /// A limit replaces the last one, narrower or wider, and applies to a version + /// without AUTH too. #[test] - fn a_narrowing_only_ever_narrows() { + fn a_limit_replaces_the_last() { let handle = Handle::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.narrow(&grant(&["room/bob"], &["room/alice"])); + handle.limit(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.narrow(&grant(&[""], &["room/alice/video"])); + handle.limit(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); - assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); + assert!(handle.allows(Direction::Subscribe, "other")); - handle.narrow(&Grant::all()); - assert!(!handle.allows(Direction::Publish, "room/alice/audio")); - assert!(!handle.allows(Direction::Subscribe, "other")); + handle.limit(&Grant::all()); + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + assert!(handle.allows(Direction::Subscribe, "other")); } - /// Our own grant does not decide what the peer may offer us, only the ceiling does. + /// Our own grant does not decide what the peer may offer us, only the limit does. #[test] - fn the_ceiling_alone_decides_what_the_peer_offers() { + fn the_limit_alone_decides_what_the_peer_offers() { let handle = Handle::new(true); let token = handle.present(Bytes::new(), true).unwrap(); handle.granted(0, grant(&[], &[])); assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); - assert!(handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.narrow(&grant(&["room/alice"], &[])); - assert!(!handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); - assert!(handle.within_ceiling(Direction::Subscribe, "room/alice/cam")); + handle.limit(&grant(&["room/alice"], &[])); + assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); } - /// The default acceptor answers with its base grant, then with each grant a - /// narrowing changes, and stays quiet on a change that leaves it alone. + /// The default acceptor answers with its base grant, then with each grant a new + /// limit changes, narrower or wider, and stays quiet on a change that leaves it alone. #[test] - fn the_default_grant_follows_the_ceiling() { + fn the_default_grant_follows_the_limit() { let handle = Handle::new(true); let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); let waiter = kio::Waiter::noop(); @@ -1059,11 +1057,15 @@ mod tests { assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.narrow(&grant(&["room"], &["room/alice"])); + handle.limit(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); - // Wider than the ceiling already is: nothing to tell the peer. - handle.narrow(&Grant::all()); + // Wider again, but never past what the origin handles allow. + handle.limit(&Grant::all()); + assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); + + // A limit that leaves the grant as it is: nothing to tell the peer. + handle.limit(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 663e3f34b3..91cafad15f 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -408,7 +408,7 @@ impl Client { }; let stream = stream.with_version(v); - // Draft 14-16 carry no AUTH, but the session still narrows through this handle. + // Draft 14-16 carry no AUTH, but the session is still limited through this handle. let auth = crate::auth::Handle::new(false); // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index bc65d529e8..cf7e591ba7 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -413,7 +413,7 @@ impl Serve { } return; } - // Re-sent whenever a narrowing changes it. + // Re-sent whenever a new limit changes it. None => default = Some(crate::auth::DefaultGrant::new(self.handle.clone(), self.peer_grant)), } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 9d6a963996..45141d585d 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -154,7 +154,7 @@ where let control = Control::new(request_id_max, client); let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version); - // No AUTH on these drafts, but a narrowing still reaches both halves. + // No AUTH on these drafts, but a limit still reaches both halves. let publisher = Publisher::new( runtime.clone(), adapter.clone(), diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 071090d6ae..ae86883946 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -393,10 +393,14 @@ struct BroadcastState { route: crate::origin::Route, // The served route: dropping it (and the serve task's clone) retracts the - // route and rejects its queued requests. `None` once a narrowing took it away - // while the peer still advertises it. + // route and rejects its queued requests. `None` while the session's limit holds + // it back, though the peer still advertises it. dynamic: Option, + // Bumped each time the route attaches, so a serve task outlived by a limit that + // took the route away and gave it back ends rather than serve beside the new one. + generation: u64, + // active number of PUBLISH_NAMESPACE messages. count: usize, @@ -515,12 +519,65 @@ where } } - /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth). + /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth), + /// and what the peer may publish to us by the session's limit, as either changes. pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { self.auth = auth; + let this = self.clone(); + self.tasks.push(async move { this.run_limit().await }); self } + /// Follow the session's limit, attaching every withheld namespace a new limit + /// covers. Each route's own serve task holds itself back when a limit no longer + /// covers it. + async fn run_limit(&self) { + let mut epoch = 0; + loop { + let permit = kio::wait(|waiter| { + self.auth + .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + }) + .await; + let mut state = self.state.lock(); + let mut attached = Vec::new(); + for (path, entry) in state.broadcasts.iter_mut() { + let allowed = permit.within_limit(path.as_str()); + match &entry.dynamic { + None if allowed => { + let mut route = entry.route.clone(); + if self.going_away.is_set() { + route.cost = crate::origin::Cost::DRAIN; + } + let Ok(dynamic) = self.origin.dynamic(path, route) else { + continue; + }; + tracing::info!(route = %self.origin.absolute(path), "namespace authorized again"); + entry.dynamic = Some(dynamic); + entry.generation += 1; + attached.push((path.clone(), entry.generation)); + } + _ => {} + } + } + drop(state); + for (path, generation) in attached { + self.serve_route(path, generation); + } + } + } + + /// Serve the requests beneath one attached namespace on its own task. + fn serve_route(&self, path: PathOwned, generation: u64) { + let this = self.clone(); + self.tasks.push(async move { + // stop_announce is the authoritative remover: it drops the entry + // (retracting the route) once the announce refcount hits zero, + // which is what makes run_route exit, as does a limit holding it back. + this.run_route(path, generation).await; + }); + } + /// End every active subscription with the error that ended the session. pub fn abort(&self, err: &Error) { self.state.lock().abort(err); @@ -1378,12 +1435,18 @@ where Ok(()) } Entry::Vacant(entry) => { - // Nothing the session no longer lets the peer publish is accepted. - if !self - .auth - .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) - { - return Err(Error::Unauthorized); + // Outside the session's limit: held, not refused, so a wider limit can + // attach it while the peer still advertises it. + if !self.auth.within_limit(crate::auth::Direction::Subscribe, path.as_str()) { + tracing::debug!(route = %self.origin.absolute(&path), "withholding announce outside the limit"); + entry.insert(BroadcastState { + route, + dynamic: None, + generation: 0, + count: 1, + sources: HashMap::new(), + }); + return Ok(()); } // Propagates Error::Unauthorized if the namespace is out of scope. let dynamic = self.origin.dynamic(&path, route.clone())?; @@ -1391,19 +1454,13 @@ where entry.insert(BroadcastState { route, dynamic: Some(dynamic), + generation: 0, count: 1, sources: HashMap::new(), }); tracing::debug!(route = %self.origin.absolute(&path), "announce"); - - let this = self.clone(); - self.tasks.push(async move { - // stop_announce is the authoritative remover: it drops the entry - // (retracting the route) once the announce refcount hits zero, - // which is what makes run_route exit. - this.run_route(path).await; - }); + self.serve_route(path, 0); Ok(()) } @@ -1450,7 +1507,7 @@ where /// per requested path and serve its track requests until the route is /// retracted or the session dies. Tracks in flight at a retraction run to /// their own end. - async fn run_route(&self, path: PathOwned) { + async fn run_route(&self, path: PathOwned, generation: u64) { let mut broadcasts = TaskSet::owned(); let mut closed_session = self.session.clone(); let mut epoch = 0; @@ -1468,15 +1525,15 @@ where if self.going_away.poll(waiter).is_ready() { self.drain_route(&path); } - // A narrowing that excludes the namespace takes the route away as a + // A limit that no longer covers the namespace holds the route back as a // retraction would, closing its sources. Tracks in flight end on their // own gates, with `Unauthorized`. - let mut narrowed = false; + let mut excluded = false; while let Poll::Ready(permit) = self.auth .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) { - narrowed |= !permit.within_ceiling(path.as_str()); + excluded = !permit.within_limit(path.as_str()); } // The route lives in the entry: stop_announce removing it retracts // the route, and this loop ends with it. @@ -1484,7 +1541,10 @@ where let Some(entry) = state.broadcasts.get_mut(&path) else { return Poll::Ready(None); }; - if narrowed && entry.dynamic.is_some() { + if entry.generation != generation { + return Poll::Ready(None); + } + if excluded && entry.dynamic.is_some() { tracing::info!(route = %self.origin.absolute(&path), "namespace no longer authorized"); entry.dynamic = None; entry.sources.clear(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0c9605999f..cbff7dea57 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -358,7 +358,7 @@ struct AuthServe { /// filled in by the default acceptor. issue: Option>, /// The default acceptor's grant for the connection's credential, re-sent as a - /// narrowing changes it. `None` when the app answers. + /// new limit changes it. `None` when the app answers. default: Option, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index cbe26042f4..07995ddfd0 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -326,27 +326,7 @@ impl Subscriber { // outside our scope, so don't serve it. Reflections are already // filtered above. let route = self.announced_route(hops, cost, link_cost, responder_origin); - let Some(dynamic) = self.serve_route(&path, route.clone()) else { - return Ok(false); - }; - - announced.attach(path, route, dynamic); - - Ok(true) - } - - /// Announce the peer's route into the origin, so paths under it resolve through - /// this session on demand. `None` when we may not accept it: outside our origin's - /// scope, or outside what the session still lets the peer publish. - fn serve_route(&self, path: &Path, route: crate::origin::Route) -> Option { - if !self - .auth - .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) - { - tracing::debug!(route = %self.log_path(path), "declining announce outside the narrowed grant"); - return None; - } - self.origin.dynamic(path, route).ok() + Ok(announced.offer(self, path, route)) } /// The route to announce for a prefix this peer advertised, charging our @@ -442,13 +422,7 @@ impl Subscriber { return Ok(true); } - let Some(dynamic) = self.serve_route(&path, metadata.clone()) else { - announced.declined(path); - return Ok(false); - }; - announced.attach(path, metadata, dynamic); - - Ok(true) + Ok(announced.offer(self, path, metadata)) } /// Remove a subscription, releasing the session's handle on its producer. @@ -1166,7 +1140,7 @@ struct PrefixRun { // path, and lite-07 bases name it too. Tracked even for announces we drop // locally (reflected loops), since the sender doesn't know we dropped them. decoder: lite::AnnounceDecoder, - /// The auth epoch last applied, so a narrowing drops the routes it excludes. + /// The auth epoch last applied, so a new limit holds back or brings back routes. epoch: u64, } @@ -1295,14 +1269,15 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } - // A narrowing aborts what the peer may no longer publish to us. The - // peer still holds each advertisement, so the path stays reserved. + // A new limit holds back what the peer may no longer publish to us, + // and brings back what it may again. The peer still holds each + // advertisement either way. while let Poll::Ready(permit) = self.subscriber .auth .poll_permit(crate::auth::Direction::Subscribe, &mut run.epoch, waiter) { - run.announced.narrow(&permit, &self.subscriber); + run.announced.limit(&permit, &self.subscriber); } loop { match stream.reader.poll_decode_maybe::(&mut cx) { @@ -1682,10 +1657,10 @@ mod tests { assert!(wire.is_empty(), "a second SUBSCRIBE trailed the first"); } - /// A narrowing that lands while the SUBSCRIBE is still opening resets it with + /// A narrower limit landing while the SUBSCRIBE is still opening resets it with /// UNAUTHORIZED, like a live one, and releases its id. #[tokio::test] - async fn a_narrowing_resets_a_subscribe_still_opening() { + async fn a_narrower_limit_resets_a_subscribe_still_opening() { // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. let gate = kio::Producer::new(false); let session = SinkSession::gated_bi(gate.consume()); @@ -1730,7 +1705,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.narrow(&crate::auth::Grant::default()); + auth.limit(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); @@ -2846,13 +2821,24 @@ enum Sub { /// owns its path and announce id until it retracts or restarts it. #[derive(Default)] struct Announced { - routes: HashMap>, + routes: HashMap, /// Attached routes whose request queue woke since the last serve pass. The /// driver wakes for every group the session carries, so a pass must cost what /// was requested, not every route the peer announced. ready: kio::Queue, } +/// What we made of one advertisement the peer holds. +enum Held { + /// Not in the origin for good: a reflection, or outside our origin's scope. + Declined, + /// Outside the session's limit: the route as advertised, attached once a + /// limit allows it. + Withheld(crate::origin::Route), + /// In the origin, serving requests. + Attached(AnnouncedRoute), +} + impl Announced { fn contains(&self, path: &PathOwned) -> bool { self.routes.contains_key(path) @@ -2867,12 +2853,42 @@ impl Announced { }); let route = AnnouncedRoute::new(route, dynamic, wake); route.waker.wake_by_ref(); - self.routes.insert(path, Some(route)); + self.routes.insert(path, Held::Attached(route)); + } + + /// Put the peer's route into the origin, so paths under it resolve through this + /// session on demand, or hold it back when the session's limit does not cover it. + /// Returns whether it attached. + fn offer( + &mut self, + subscriber: &Subscriber, + path: PathOwned, + route: crate::origin::Route, + ) -> bool { + if !subscriber + .auth + .within_limit(crate::auth::Direction::Subscribe, path.as_str()) + { + tracing::debug!(route = %subscriber.log_path(&path), "withholding announce outside the limit"); + self.routes.insert(path, Held::Withheld(route)); + return false; + } + // An error means the prefix is outside our origin's scope, so don't serve it. + match subscriber.origin.dynamic(&path, route.clone()) { + Ok(dynamic) => { + self.attach(path, route, dynamic); + true + } + Err(_) => { + self.declined(path); + false + } + } } fn declined(&mut self, path: PathOwned) { // Dropping a replaced route closes its sources. - self.routes.insert(path, None); + self.routes.insert(path, Held::Declined); } /// Record an advertisement before deciding what to do with it. @@ -2886,11 +2902,14 @@ impl Announced { /// with [`Self::contains`]. Overwriting an attached route is [`Self::declined`]'s job. fn reserve(&mut self, path: PathOwned) { debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); - self.routes.insert(path, None); + self.routes.insert(path, Held::Declined); } fn attached(&mut self, path: &PathOwned) -> Option<&mut AnnouncedRoute> { - self.routes.get_mut(path)?.as_mut() + match self.routes.get_mut(path)? { + Held::Attached(route) => Some(route), + _ => None, + } } fn retire(&mut self, path: &PathOwned) { @@ -2905,7 +2924,7 @@ impl Announced { let root = subscriber.origin.root().to_owned(); while let Poll::Ready(Ok(path)) = self.ready.poll_pop(waiter) { // A route retired since it woke has nothing left to serve. - let Some(Some(entry)) = self.routes.get_mut(&path) else { + let Some(Held::Attached(entry)) = self.routes.get_mut(&path) else { continue; }; // Cleared before polling, so a request landing mid-pass queues the route again. @@ -2930,21 +2949,44 @@ impl Announced { } } - /// Drop every attached route the session no longer lets the peer publish, closing - /// its sources. Tracks in flight end on their own gates, with `Unauthorized`. - fn narrow(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { - for (path, route) in self.routes.iter_mut() { - if route.is_some() && !permit.within_ceiling(path.as_str()) { - tracing::info!(route = %subscriber.log_path(path), "announce no longer authorized"); - *route = None; + /// Apply a new limit: hold back every attached route it no longer covers, closing + /// its sources (tracks in flight end on their own gates, with `Unauthorized`), and + /// attach every withheld one it now does. + fn limit(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { + let changed: Vec = self + .routes + .iter() + .filter(|(path, held)| match held { + Held::Attached(_) => !permit.within_limit(path.as_str()), + Held::Withheld(_) => permit.within_limit(path.as_str()), + Held::Declined => false, + }) + .map(|(path, _)| path.clone()) + .collect(); + for path in changed { + match self.routes.remove(&path) { + Some(Held::Attached(entry)) => { + tracing::info!(route = %subscriber.log_path(&path), "announce no longer authorized"); + self.routes.insert(path, Held::Withheld(entry.route.clone())); + } + Some(Held::Withheld(mut route)) => { + tracing::info!(route = %subscriber.log_path(&path), "announce authorized again"); + if subscriber.going_away.is_set() { + route.cost = crate::origin::Cost::DRAIN; + } + self.offer(subscriber, path, route); + } + _ => unreachable!("only attached and withheld routes change"), } } } /// Re-price every attached route to a draining cost (the peer sent a GOAWAY). fn drain(&mut self) { - for entry in self.routes.values_mut().flatten() { - entry.drain(); + for held in self.routes.values_mut() { + if let Held::Attached(entry) = held { + entry.drain(); + } } } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 4f2a1aaeef..7bf0d3b338 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -136,15 +136,22 @@ async fn granted(session: &Session) -> Grant { async fn wait_announced(origin: &origin::Consumer, path: &str, active: bool) { let mut announced = origin.announced(); let mut live = std::collections::HashSet::new(); - loop { - if live.contains(path) == active { - return; - } - let update = announced.next().await.expect("origin closed"); + let apply = |live: &mut std::collections::HashSet, update: moq_net::announce::Update| { match update.kind.is_active() { true => live.insert(update.prefix.to_string()), false => live.remove(update.prefix.as_str()), }; + }; + // Take in the replay first, so a retraction is judged against what is announced now + // rather than against an empty start. + while let Some(update) = futures::FutureExt::now_or_never(announced.next()).flatten() { + apply(&mut live, update); + } + loop { + if live.contains(path) == active { + return; + } + apply(&mut live, announced.next().await.expect("origin closed")); } } @@ -973,26 +980,31 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { .expect("timed out"); } -/// Run each narrowing case on every version family: the session enforces a narrowing -/// itself, so a peer without AUTH (or one that ignores it) cannot keep what it lost. -macro_rules! narrow_cases { +/// Run each limit case on every version family: the session enforces its limit itself, +/// so a peer without AUTH (or one that ignores it) cannot keep what it lost. +macro_rules! limit_cases { ($($case:ident),* $(,)?) => { - mod narrow_lite_05 { + mod limit_lite_05 { $(#[tokio::test] async fn $case() { super::$case("moq-lite-05").await })* } - mod narrow_lite_06 { + mod limit_lite_06 { $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* } - mod narrow_moqt_16 { + mod limit_moqt_16 { $(#[tokio::test] async fn $case() { super::$case("moq-transport-16").await })* } - mod narrow_moqt_17 { + mod limit_moqt_17 { $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* } }; } -narrow_cases!(a_narrowing_deafens_one_path, a_narrowing_aborts_what_the_peer_published); +limit_cases!( + a_narrowing_deafens_one_path, + a_narrowing_aborts_what_the_peer_published, + a_widening_brings_back_a_deafened_path, + a_widening_brings_back_what_the_peer_published, +); #[tokio::test] async fn lite_05_narrowing_resets_a_fetch_in_flight() { @@ -1063,7 +1075,7 @@ async fn a_narrowing_deafens_one_path(version: &'static str) { let mut video_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); video_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().narrow(&grant(&[], &["room/alice/video"])); + pair.server.auth().limit(&grant(&[], &["room/alice/video"])); let err = ended(&mut audio_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1135,7 +1147,7 @@ async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { let mut cam_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); cam_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().narrow(&grant(&["room/bob/cam"], &[])); + pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); let err = ended(&mut mic_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1181,7 +1193,7 @@ async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { let frame = fetched.read_frame().await.unwrap().expect("first frame"); assert_eq!(frame.payload.as_ref(), b"first"); - pair.server.auth().narrow(&grant(&[], &["room/y"])); + pair.server.auth().limit(&grant(&[], &["room/y"])); let err = loop { match fetched.read_frame().await { @@ -1198,3 +1210,110 @@ async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { .await .expect("timed out"); } + +/// Read groups until one at `sequence` or later arrives. +async fn recv_through(sub: &mut moq_net::track::Subscriber, sequence: u64) { + loop { + let group = sub.recv_group().await.unwrap().expect("track ended"); + if group.sequence >= sequence { + return; + } + } +} + +/// Widening the limit after a narrowing brings the deafened path back: it is announced +/// again and a new subscription to it flows. +async fn a_widening_brings_back_a_deafened_path(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let relay = produce_origin(1); + let audio = relay.create_broadcast("room/alice/audio").unwrap(); + let audio_track = audio.create_track("opus", None).unwrap(); + audio.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), + ..Default::default() + }) + .await; + + let mut group = audio_track.append_group().unwrap(); + group.write_frame(ts(0), b"a".as_ref()).unwrap(); + let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); + let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + sub.recv_group().await.unwrap().unwrap(); + + pair.server.auth().limit(&grant(&[], &["room/alice/video"])); + let err = ended(&mut sub).await; + assert!(unauthorized(&err), "{err:?}"); + wait_announced(&received.consume(), "room/alice/audio", false).await; + drop((sub, remote)); + + pair.server.auth().limit(&grant(&[], &["room"])); + wait_announced(&received.consume(), "room/alice/audio", true).await; + let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); + let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = audio_track.append_group().unwrap(); + group.write_frame(ts(1), b"a".as_ref()).unwrap(); + recv_through(&mut sub, 1).await; + + // A peer that speaks AUTH is told it may subscribe again. + if speaks_auth(version) { + let widened = wait_for(pair.client.auth().grant(), |grant| { + grant + .as_ref() + .is_some_and(|grant| grant.subscribe == patterns(&["room"])) + }) + .await; + assert_eq!(widened, Some(grant(&[], &["room"]))); + } + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// Widening the limit after a narrowing brings back what the peer published: its route +/// is in the origin again and the relay's own readers can subscribe to it. +async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let client_origin = produce_origin(2); + let mic = client_origin.create_broadcast("room/bob/mic").unwrap(); + let mic_track = mic.create_track("opus", None).unwrap(); + mic.announce(Default::default()).unwrap(); + + let relay = produce_origin(1); + let pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(relay.clone()), + ..Default::default() + }) + .await; + wait_announced(&relay.consume(), "room/bob/mic", true).await; + + pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); + wait_announced(&relay.consume(), "room/bob/mic", false).await; + + pair.server.auth().limit(&grant(&["room"], &[])); + wait_announced(&relay.consume(), "room/bob/mic", true).await; + let remote = relay.consume().routed_broadcast("room/bob/mic").await.unwrap(); + let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = mic_track.append_group().unwrap(); + group.write_frame(ts(0), b"m".as_ref()).unwrap(); + recv_through(&mut sub, 0).await; + + // Neither the narrowing nor the widening is a publication outside the grant. + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 266384d407..b532de4cdb 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -412,21 +412,13 @@ impl Token { } /// Whether `other` still covers everything this token scopes: the same root and - /// mounts, and every grant still held. A narrower re-check narrows the session. + /// mounts, and every grant still held. pub(crate) fn covered_by(&self, other: &Self) -> bool { self.root == other.root && self.mounts == other.mounts && other.subscribe.covers(&self.subscribe) && other.publish.covers(&self.publish) } - - /// Keep only what `other` also grants. Fails closed: an intersection too large - /// to hold grants nothing. - fn narrow(&mut self, other: &Self) { - let both = |this: &Patterns, other: &Patterns| this.intersect(other).unwrap_or_default(); - self.subscribe = both(&self.subscribe, &other.subscribe); - self.publish = both(&self.publish, &other.publish); - } } /// What admitted a session and keeps it admitted: the grant's lease and the @@ -439,7 +431,7 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, - /// The live session a narrower re-check narrows in place, once attached. + /// The live session a re-check resizes in place, once attached. session: Option, /// When the grant runs out, enforced here whoever drives the lease: a fixed /// grant has no driver, and an auth server's may be mid-outage. Fixed on tokio's @@ -464,9 +456,9 @@ impl Lease { } } - /// Narrow `session` in place when a re-check narrows the grant, instead of ending - /// the lease. Without it a narrower grant ends the lease. - pub(crate) fn narrowing(mut self, session: &moq_net::Session) -> Self { + /// Resize `session` in place when a re-check changes what the grant allows, + /// narrower or wider. Without it a narrower grant ends the lease. + pub(crate) fn limiting(mut self, session: &moq_net::Session) -> Self { self.session = Some(session.auth()); self } @@ -477,8 +469,8 @@ impl Lease { self } - /// The scope the session holds: what it was admitted under, less what any - /// re-check narrowed away since. + /// The scope the session holds: what it was admitted under, with the patterns of + /// the latest re-check that resized it. pub fn token(&self) -> &Token { &self.token } @@ -491,9 +483,10 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A narrower grant with the same root and mounts narrows an attached session in - /// place and keeps going; the session keeps only what both grants allow, so a - /// wider part waits for a reconnect. Without an attached session it ends the lease. + /// A grant with the same root and mounts sets an attached session's limit to its + /// patterns, narrower or wider, and keeps going; the origin handles the session was + /// admitted with still bound it, so it never reaches past them. Without an attached + /// session a narrower grant ends the lease and a wider one changes nothing. /// A changed root or mounts ends it, and so does a flipped `peer`, since the /// routes it already announced would be misreported as entering here or from a /// peer. A changed tier keeps the session and moves its @@ -521,21 +514,25 @@ impl Lease { if fresh.peer != self.token.peer { return "peer changed".into(); } - if !self.token.covered_by(&fresh) { - let Some(session) = &self.session else { - return "grant narrowed".into(); - }; - self.token.narrow(&fresh); - tracing::info!( - publish = %join(&self.token.publish), - subscribe = %join(&self.token.subscribe), - "grant narrowed; narrowing the session", - ); - session.narrow(&moq_net::auth::Grant { - publish: self.token.publish.clone(), - subscribe: self.token.subscribe.clone(), - expires: None, - }); + if fresh.publish != self.token.publish || fresh.subscribe != self.token.subscribe { + match &self.session { + Some(session) => { + tracing::info!( + publish = %join(&fresh.publish), + subscribe = %join(&fresh.subscribe), + "grant changed; resizing the session", + ); + session.limit(&moq_net::auth::Grant { + publish: fresh.publish.clone(), + subscribe: fresh.subscribe.clone(), + expires: None, + }); + self.token.publish = fresh.publish; + self.token.subscribe = fresh.subscribe; + } + None if !self.token.covered_by(&fresh) => return "grant narrowed".into(), + None => {} + } } if fresh.tier != self.token.tier { tracing::info!(from = %self.token.tier, to = %fresh.tier, "tier changed"); diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index f143a818f3..98e198eb52 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -275,8 +275,8 @@ pub(crate) fn authorize( /// the internal listener re-checks this lease; `None` for a session that is /// not in the table. /// -/// The lease is the decider's live word on the grant: a narrower re-check narrows -/// the session in place, and when it stops covering the session +/// The lease is the decider's live word on the grant: a re-check resizes the +/// session in place, narrower or wider, and when it stops covering the session /// ([`auth::Lease::ended`]) the session closes with the reason. The session's own /// close is reported back through the lease as the `end` event. Either way, a relay /// shutdown drains the session with a GOAWAY instead of cutting it off. @@ -289,7 +289,7 @@ pub async fn supervise( mut shutdown: crate::shutdown::Observer, registration: Option, ) -> anyhow::Result<()> { - let mut lease = lease.narrowing(&session); + let mut lease = lease.limiting(&session); loop { let nudged = async { match ®istration { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 192051ae49..b33b9bb1af 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -197,7 +197,7 @@ where // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); - let mut lease = lease.narrowing(&session); + let mut lease = lease.limiting(&session); loop { let nudged = async { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 3cf3fb0438..4b79978418 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -4,7 +4,7 @@ //! or QUIC) or its axum WebSocket path (`serve_ws` over `ws://`), points it at a //! scripted auth server, connects a publisher and a subscriber, confirms media //! flows, then asserts the relay follows the server's word: a re-check that moves -//! the tier retags the live session's stats, a narrower grant narrows it in place, a +//! the tier retags the live session's stats, a changed grant resizes it in place, a //! moved root or a refusal closes it, an outage keeps it until `expires`, and every //! close reports `end` with what it moved. //! The last tests swap the server for an in-process decider answering @@ -556,11 +556,11 @@ async fn a_moved_tier_retags_the_live_session() { relay.abort(); } -/// A narrower grant narrows the live session in place on the next re-check, over TCP -/// and WebSocket: the deafened path resets with `Unauthorized`, a sibling under the -/// same prefix keeps flowing, and neither session closes. +/// A re-check resizes the live session in place, over TCP and WebSocket: a narrower +/// grant resets the deafened path with `Unauthorized` while a sibling under the same +/// prefix keeps flowing, a wider one brings the path back, and neither session closes. #[tokio::test] -async fn a_narrower_grant_narrows_live_sessions() { +async fn a_rechecked_grant_resizes_live_sessions() { for scheme in ["tcp", "ws"] { let script = Script::new(grant(Duration::from_secs(3600))); let auth = build_auth(script.spawn().await); @@ -667,12 +667,32 @@ async fn a_narrower_grant_narrows_live_sessions() { .expect("the sibling track closed"); assert_eq!(group.sequence, 1, "{scheme}"); + // Undeafen: the path is announced again and a new subscription flows. + script.on_revalidate(Answer::Grant(grant(Duration::from_secs(3600)))); + let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.routed_broadcast("alice/audio")) + .await + .expect("the undeafened path was never announced again") + .expect("announced broadcast resolves"); + let mut audio = broadcast + .track("media") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + send(0, 1); + let group = tokio::time::timeout(TIMEOUT, audio.recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("the undeafened track closed"); + assert!(group.sequence >= 1, "{scheme}"); + for (session, what) in [(pub_session, "publisher"), (sub_session, "subscriber")] { assert!( tokio::time::timeout(Duration::from_millis(200), session.closed()) .await .is_err(), - "{scheme}: a narrowing must not close the {what}" + "{scheme}: a resize must not close the {what}" ); } relay.abort(); From ee8d254ac0715206bd948e05664fd0e1389244fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 17:51:53 -0700 Subject: [PATCH 09/11] chore(net): box the attached route Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 07995ddfd0..8bf3e12a1a 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2836,7 +2836,7 @@ enum Held { /// limit allows it. Withheld(crate::origin::Route), /// In the origin, serving requests. - Attached(AnnouncedRoute), + Attached(Box), } impl Announced { @@ -2853,7 +2853,7 @@ impl Announced { }); let route = AnnouncedRoute::new(route, dynamic, wake); route.waker.wake_by_ref(); - self.routes.insert(path, Held::Attached(route)); + self.routes.insert(path, Held::Attached(Box::new(route))); } /// Put the peer's route into the origin, so paths under it resolve through this @@ -2907,7 +2907,7 @@ impl Announced { fn attached(&mut self, path: &PathOwned) -> Option<&mut AnnouncedRoute> { match self.routes.get_mut(path)? { - Held::Attached(route) => Some(route), + Held::Attached(route) => Some(route.as_mut()), _ => None, } } From dc2b9861373244711e06cbde9b9ad0ce92c01561 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 19:24:37 -0700 Subject: [PATCH 10/11] refactor(net): rename auth::Handle::limit to authorize Co-Authored-By: Claude Opus 5.5 --- doc/lib/rs/moq-net.md | 18 ++++++++-------- quest/m1/auth/README.md | 2 +- quest/m1/auth/relay-refresh.md | 16 +++++++------- quest/m1/auth/request-token.md | 2 +- rs/moq-net/benches/origin.rs | 4 ++-- rs/moq-net/src/auth.rs | 35 ++++++++++++++++--------------- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/tests/auth.rs | 14 ++++++------- rs/moq-relay/src/auth.rs | 12 +++++------ rs/moq-relay/src/connection.rs | 4 ++-- rs/moq-relay/src/websocket.rs | 2 +- 11 files changed, 56 insertions(+), 55 deletions(-) diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 4e4c571b24..fbbe0477d2 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -104,27 +104,27 @@ shrinks withdraws the announcements and cancels the subscriptions it no longer covers, and leaves the session up. On moq-lite each cancelled stream resets with `StreamError::Unauthorized`. -`session.auth().limit(&grant)` sets the most the peer may do on a live session, -replacing any earlier limit, on every version, whether or not the peer speaks -AUTH: the session enforces it itself, and never past its origin handles. A -narrower limit ends what falls outside at once: announcements to the peer +`session.auth().authorize(&grant)` authorizes the peer for `grant` on a live +session; calling it again re-authorizes, narrower or wider. It works on every +version, whether or not the peer speaks AUTH: the session enforces it itself, and +never past its origin handles. A narrower grant ends what falls outside at once: announcements to the peer retract, its new requests are refused, its subscriptions and fetches reset with `Unauthorized`, and the broadcasts it published abort, so local readers see -`Unauthorized` too. A wider one brings back what the old limit held back: +`Unauthorized` too. A wider one brings back what the old grant held back: announcements are made again, the peer's own announcements return to the origin, and new requests are accepted. The rest of the session carries on. When the -session answers the peer's connection credential itself, the peer is sent its -grant within the limit. +session answers the peer's connection credential itself, the peer is sent the +grant. ```rust // Deafen alice's audio: the peer may still subscribe to her video. let video: Pattern = "room/alice/video/**".parse()?; -session.auth().limit(&auth::Grant { +session.auth().authorize(&auth::Grant { subscribe: video.into(), ..auth::Grant::all() }); // Undeafen. -session.auth().limit(&auth::Grant::all()); +session.auth().authorize(&auth::Grant::all()); ``` ## Patterns diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index f22d0f3353..6f467ec3e5 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,7 +39,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - `auth::Handle::limit` is the common resize operation; relay token handling + `auth::Handle::authorize` is the common resize operation; relay token handling uses it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 00813f65fc..8176895768 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -30,28 +30,28 @@ expiry, an expiry that leaves the union intact ends only that token, and sessions reject a non-empty token as `Unsupported`. - Union: the connection holds the set of accepted leases. Every one must carry the admitted root, else `AUTH_ERROR { Unauthorized }` naming the - root. The session's limit is the union of the set's publish and subscribe + root. The peer is authorized for the union of the set's publish and subscribe patterns, intersected with the role the client declared at SETUP so a publish-only session never starts receiving announcements because a later - token happened to carry subscribe prefixes, and set through - `auth::Handle::limit` whenever the union changes, grown or shrunk; keep it - behind one function. A limit never reaches past the session's origin handles, + token happened to carry subscribe prefixes, re-authorized through + `auth::Handle::authorize` whenever the union changes, grown or shrunk; keep it + behind one function. A grant never reaches past the session's origin handles, so scope those to the admitted root and role rather than to the first token's patterns, and nothing is rebuilt or swapped in. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session - continues, shrunk sets the smaller limit, which resets each stream that + continues, shrunk re-authorizes the smaller union, which resets each stream that lost access with `UNAUTHORIZED`. A token withdrawn by the client (its stream closed or reset) recomputes the union the same way with nothing written back, since the stream is gone: unchanged - means the session continues, shrunk sets the limit the same way. Each lease revalidates on + means the session continues, shrunk re-authorizes the same way. Each lease revalidates on its own cadence, and every re-check that changes the grant, a lower `expires` or a different `publish` or `subscribe`, writes the complete replacement grant as an update AUTH_OK on that token's stream and recomputes the union from the latest grant of every token, so the client can present a replacement in time and a narrowed token never keeps its old - scope; a changed union sets the limit as above. + scope; a changed union re-authorizes as above. - Refusals map as connect-time ones do and never expose a status: every refusal `Client::attach` returns for a new token, a `403`, an empty or invalid grant, an unparseable body, or a `5xx`, is @@ -74,7 +74,7 @@ expiry, an expiry that leaves the union intact ends only that token, and session past the first token's expiry, with only the first stream ending; an expired or invalid token is refused and nothing changes; a token with a different root is refused naming it; a token adding a prefix widens the - limit and the prefix is now solicited; withdrawing the only token + grant and the prefix is now solicited; withdrawing the only token covering a prefix resets that prefix's streams with `UNAUTHORIZED` and the session stays connected; an mTLS session refuses a token; a proxy-mode outage on re-check keeps the token. diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index f31f9094b9..a22f24e113 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - `auth::Handle::limit`. + `auth::Handle::authorize`. - Relay: each such request gets its own lease from a per-request call on `moq_auth::Client`, not the `Client::attach` that [Relay tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index d1d426ee69..b78bfe0813 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -540,7 +540,7 @@ fn bench_narrow_delivery(c: &mut Criterion) { // Covers every subscription but the muted one, which is not subscribed here. let mut grant = room(); grant.subscribe.insert(Pattern::literal("muted").unwrap()); - watching.pair.server.auth().limit(&grant); + watching.pair.server.auth().authorize(&grant); } b.iter(|| rt.block_on(watching.round())); }); @@ -566,7 +566,7 @@ fn bench_narrow(c: &mut Criterion) { for _ in 0..iters { let mut watching = rt.block_on(watching(routes, subscribers)); let start = std::time::Instant::now(); - watching.pair.server.auth().limit(&room()); + watching.pair.server.auth().authorize(&room()); let ended = rt.block_on(watching.subscriptions[0].recv_group()); elapsed += start.elapsed(); assert!(ended.is_err(), "the muted subscription outlived the narrowing"); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index a8f19e4cd0..6403a3ac79 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -11,8 +11,8 @@ //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's //! driver, and then answers every token the peer presents. Either way, -//! [`limit`](Handle::limit) resizes what the peer may do on a live session, on -//! every version, whether or not it speaks AUTH. +//! [`authorize`](Handle::authorize) re-authorizes the peer on a live session, narrower +//! or wider, on every version, whether or not it speaks AUTH. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -115,7 +115,8 @@ pub(crate) struct State { /// The peer replied to some token, so the union is known even when empty. replied: bool, /// The most this side lets the peer do, in the peer's terms: `None` (only the - /// origin handles bound it) until [`Handle::limit`] sets it. + /// origin handles bound it) until [`Handle::authorize`] sets it; the grant it was + /// last called with. limit: Option, /// Bumped whenever the union or the limit changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. @@ -439,22 +440,22 @@ impl Handle { Poll::Ready(()) } - /// Set the most the peer may do on this session to `grant`, in the session's own - /// paths, replacing any earlier limit. The session's origin handles still bound it, - /// so a limit never grants more than they allow. + /// Authorize the peer for `grant` on this session, in the session's own paths. + /// Calling it again re-authorizes, narrower or wider, replacing the last grant. The + /// session's origin handles still bound it, so it never grants more than they allow. /// - /// A narrower limit ends what falls outside at once, with [`Error::Unauthorized`] + /// A narrower grant ends what falls outside at once, with [`Error::Unauthorized`] /// where it has a reader: announcements to the peer retract, its new requests are /// refused, its subscriptions reset, and the broadcasts it published abort. A wider - /// one brings back what the old limit held back: announcements to the peer are made + /// one brings back what the old grant held back: announcements to the peer are made /// again, and so are the peer's announcements it withheld from the origin, and new /// requests are accepted. The rest of the session carries on. It works on every /// version, since the session enforces it without the peer's help. /// /// When the session answers the peer's connection credential itself, the peer is - /// told its grant within the limit, `expires` included. An application answering + /// told the grant, within what the origin handles allow, `expires` included. An application answering /// tokens through [`requests`](Self::requests) updates its own [`Issued`] grants. - pub fn limit(&self, grant: &Grant) { + pub fn authorize(&self, grant: &Grant) { let mut state = self.state.lock(); if state.limit.as_ref() != Some(grant) { state.limit = Some(grant.clone()); @@ -1014,19 +1015,19 @@ mod tests { let handle = Handle::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.limit(&grant(&["room/bob"], &["room/alice"])); + handle.authorize(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.limit(&grant(&[""], &["room/alice/video"])); + handle.authorize(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); assert!(handle.allows(Direction::Subscribe, "other")); - handle.limit(&Grant::all()); + handle.authorize(&Grant::all()); assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Subscribe, "other")); } @@ -1040,7 +1041,7 @@ mod tests { assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.limit(&grant(&["room/alice"], &[])); + handle.authorize(&grant(&["room/alice"], &[])); assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); @@ -1057,15 +1058,15 @@ mod tests { assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.limit(&grant(&["room"], &["room/alice"])); + handle.authorize(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); // Wider again, but never past what the origin handles allow. - handle.limit(&Grant::all()); + handle.authorize(&Grant::all()); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); // A limit that leaves the grant as it is: nothing to tell the peer. - handle.limit(&grant(&[""], &[""])); + handle.authorize(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 8bf3e12a1a..a4291d8f15 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1705,7 +1705,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.limit(&crate::auth::Grant::default()); + auth.authorize(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 7bf0d3b338..1a4711209e 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -1075,7 +1075,7 @@ async fn a_narrowing_deafens_one_path(version: &'static str) { let mut video_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); video_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().limit(&grant(&[], &["room/alice/video"])); + pair.server.auth().authorize(&grant(&[], &["room/alice/video"])); let err = ended(&mut audio_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1147,7 +1147,7 @@ async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { let mut cam_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); cam_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); + pair.server.auth().authorize(&grant(&["room/bob/cam"], &[])); let err = ended(&mut mic_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1193,7 +1193,7 @@ async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { let frame = fetched.read_frame().await.unwrap().expect("first frame"); assert_eq!(frame.payload.as_ref(), b"first"); - pair.server.auth().limit(&grant(&[], &["room/y"])); + pair.server.auth().authorize(&grant(&[], &["room/y"])); let err = loop { match fetched.read_frame().await { @@ -1248,13 +1248,13 @@ async fn a_widening_brings_back_a_deafened_path(version: &'static str) { let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().limit(&grant(&[], &["room/alice/video"])); + pair.server.auth().authorize(&grant(&[], &["room/alice/video"])); let err = ended(&mut sub).await; assert!(unauthorized(&err), "{err:?}"); wait_announced(&received.consume(), "room/alice/audio", false).await; drop((sub, remote)); - pair.server.auth().limit(&grant(&[], &["room"])); + pair.server.auth().authorize(&grant(&[], &["room"])); wait_announced(&received.consume(), "room/alice/audio", true).await; let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); @@ -1300,10 +1300,10 @@ async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { .await; wait_announced(&relay.consume(), "room/bob/mic", true).await; - pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); + pair.server.auth().authorize(&grant(&["room/bob/cam"], &[])); wait_announced(&relay.consume(), "room/bob/mic", false).await; - pair.server.auth().limit(&grant(&["room"], &[])); + pair.server.auth().authorize(&grant(&["room"], &[])); wait_announced(&relay.consume(), "room/bob/mic", true).await; let remote = relay.consume().routed_broadcast("room/bob/mic").await.unwrap(); let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index b532de4cdb..4c3f33e7cb 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -431,7 +431,7 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, - /// The live session a re-check resizes in place, once attached. + /// The live session a re-check re-authorizes in place, once attached. session: Option, /// When the grant runs out, enforced here whoever drives the lease: a fixed /// grant has no driver, and an auth server's may be mid-outage. Fixed on tokio's @@ -456,9 +456,9 @@ impl Lease { } } - /// Resize `session` in place when a re-check changes what the grant allows, + /// Re-authorize `session` in place when a re-check changes what the grant allows, /// narrower or wider. Without it a narrower grant ends the lease. - pub(crate) fn limiting(mut self, session: &moq_net::Session) -> Self { + pub(crate) fn authorizing(mut self, session: &moq_net::Session) -> Self { self.session = Some(session.auth()); self } @@ -483,7 +483,7 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A grant with the same root and mounts sets an attached session's limit to its + /// A grant with the same root and mounts re-authorizes an attached session for its /// patterns, narrower or wider, and keeps going; the origin handles the session was /// admitted with still bound it, so it never reaches past them. Without an attached /// session a narrower grant ends the lease and a wider one changes nothing. @@ -520,9 +520,9 @@ impl Lease { tracing::info!( publish = %join(&fresh.publish), subscribe = %join(&fresh.subscribe), - "grant changed; resizing the session", + "grant changed; re-authorizing the session", ); - session.limit(&moq_net::auth::Grant { + session.authorize(&moq_net::auth::Grant { publish: fresh.publish.clone(), subscribe: fresh.subscribe.clone(), expires: None, diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 98e198eb52..e195650745 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -275,7 +275,7 @@ pub(crate) fn authorize( /// the internal listener re-checks this lease; `None` for a session that is /// not in the table. /// -/// The lease is the decider's live word on the grant: a re-check resizes the +/// The lease is the decider's live word on the grant: a re-check re-authorizes the /// session in place, narrower or wider, and when it stops covering the session /// ([`auth::Lease::ended`]) the session closes with the reason. The session's own /// close is reported back through the lease as the `end` event. Either way, a relay @@ -289,7 +289,7 @@ pub async fn supervise( mut shutdown: crate::shutdown::Observer, registration: Option, ) -> anyhow::Result<()> { - let mut lease = lease.limiting(&session); + let mut lease = lease.authorizing(&session); loop { let nudged = async { match ®istration { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index b33b9bb1af..f67f2a5cda 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -197,7 +197,7 @@ where // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); - let mut lease = lease.limiting(&session); + let mut lease = lease.authorizing(&session); loop { let nudged = async { From 75ca4c928cf0298d7fb7ad951125178380a24f6a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 19:54:18 -0700 Subject: [PATCH 11/11] fix(net): adapt to the merged base Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/publisher.rs | 23 ++++++++++++++--------- rs/moq-net/src/lite/subscriber.rs | 5 +++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index a2f958c495..706564b19a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1089,7 +1089,7 @@ impl AnnounceRun { ) -> Result<(), Error> { match self.version { Version::Lite01 | Version::Lite02 => { - let mut init = Vec::new(); + let mut init: Vec<(crate::PathOwned, Hops, crate::origin::Cost)> = Vec::new(); // Send ANNOUNCE_INIT as the first message with all currently active routes. // We use `try_next()` to synchronously get the initial updates. @@ -1098,25 +1098,30 @@ impl AnnounceRun { let suffix = update.prefix; if update.kind.is_active() { - if !self.permitted(&suffix) || self.outgoing(&update.route, &absolute).is_none() { + if !self.permitted(&suffix) { continue; } + let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { + continue; + }; tracing::debug!(route = %absolute, "announce"); - if !init.contains(&suffix) { - init.push(suffix); + if !init.iter().any(|(p, ..)| p == &suffix) { + init.push((suffix, hops, cost)); } } else { // A potential race: a just-announced route already retracted. tracing::debug!(route = %absolute, "unannounce"); - init.retain(|p| p != &suffix); + init.retain(|(p, ..)| p != &suffix); } } - // The peer holds these now, so a later retraction or narrowing ends them. - for suffix in &init { - self.live.insert(suffix.clone(), None); + // The peer holds these now, so a later retraction or a narrower grant ends them. + let mut suffixes = Vec::with_capacity(init.len()); + for (suffix, hops, cost) in init { + self.live.insert(suffix.clone(), Advertised { id: None, hops, cost }); + suffixes.push(suffix); } - let announce_init = lite::AnnounceInit { suffixes: init }; + let announce_init = lite::AnnounceInit { suffixes }; stream.writer.buffer(&announce_init)?; } _ if self.version.has_announce_ok() => { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index cffd619ae7..06f72efcf7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1558,10 +1558,11 @@ mod tests { .subscribe(None) .await .unwrap(); + let gate = serve.gate(); let mut running = TrackServeRun { serve, state: TrackRunState::Serve(serving), - gate: None, + gate, }; kio::wait(|waiter| kio::Task::poll(&mut running, waiter)).await; @@ -3172,7 +3173,7 @@ impl Announced { /// Retire this session's advertisement without invalidating another live /// session from the same peer. Dropping its sources closes their requests. fn withdraw(&mut self, path: &PathOwned) { - if let Some(Some(entry)) = self.routes.remove(path) { + if let Some(Held::Attached(entry)) = self.routes.remove(path) { entry.dynamic.withdrawn(); } }