use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use gunnar_proto::events_server::Events;
use gunnar_proto::{Event, WatchRequest};
use tokio_stream::Stream;
use tonic::{Request, Response, Status};
use crate::auth::{Access, Caller};
use crate::bus::EventFilter;
use crate::service::{EventsService, ServiceContext};
use gunnar_proto::GrantLevel;
pub const RECHECK: Duration = Duration::from_secs(30);
struct Revalidated {
inner: crate::bus::EventStream,
ctx: ServiceContext,
caller: Caller,
scope: WatchScope,
next_check: Instant,
recheck: Duration,
refused: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum WatchScope {
Server,
Accounts {
names: Vec<String>,
outside: Vec<String>,
},
Mine,
}
fn not_shown(account: &str) -> Status {
Status::permission_denied(format!(
"Events.Watch: the events of {account} are shown only inside the account, unless its \
account admin makes them public"
))
}
impl WatchScope {
fn decide(
ctx: &ServiceContext,
caller: &Caller,
namespaces: &[String],
) -> Result<WatchScope, Status> {
let server = ctx.require(caller, Access::Server, GrantLevel::Read, "Events.Watch");
match server {
Ok(_) => return Ok(WatchScope::Server),
Err(status) if status.code() != tonic::Code::PermissionDenied => return Err(status),
Err(_) if namespaces.is_empty() => {
let own = ctx
.employer_of(&caller.principal_id)?
.unwrap_or_else(|| caller.principal_id.clone());
ctx.require(
caller,
Access::namespace(&own),
GrantLevel::Read,
"Events.Watch",
)?;
return Ok(WatchScope::Mine);
}
Err(_) => {}
}
let mut outside = Vec::new();
for account in namespaces {
let inside = ctx
.control
.may(
&caller.principal_id,
&Access::namespace(account),
GrantLevel::Read,
)
.map_err(|err| {
Status::unavailable(format!("the identity table could not be read: {err}"))
})?;
if inside {
continue;
}
let public = ctx
.control
.namespace_for_outsider(&caller.principal_id, account)?
.is_some_and(|ns| ns.events_public);
if !public {
return Err(not_shown(account));
}
outside.push(account.clone());
}
Ok(WatchScope::Accounts {
names: namespaces.to_vec(),
outside,
})
}
fn admits(&self, ctx: &ServiceContext, caller: &Caller, event: &Event) -> bool {
if let WatchScope::Mine = self {
return Self::mine_admits(ctx, caller, event);
}
let WatchScope::Accounts { outside, .. } = self else {
return Self::server_admits(ctx, caller, event);
};
match event.repo_path() {
Some(path) if outside.contains(&path.namespace) => ctx
.control
.may(&caller.principal_id, &Access::repo(path), GrantLevel::Read)
.unwrap_or(false),
_ => true,
}
}
}
impl WatchScope {
fn server_admits(ctx: &ServiceContext, caller: &Caller, event: &Event) -> bool {
use gunnar_proto::event::Payload;
match event.repo_path() {
Some(path) => match ctx.shielded(caller, &path.namespace) {
Ok(false) => true,
Ok(true) => Self::mine_admits(ctx, caller, event),
Err(_) => false,
},
None => match event.payload.as_ref() {
Some(Payload::KeyUsed(used)) => {
match ctx.account_of_principal(&used.principal_id) {
Some(account) => !ctx.shielded(caller, &account).unwrap_or(true),
None => true,
}
}
_ => true,
},
}
}
fn mine_admits(ctx: &ServiceContext, caller: &Caller, event: &Event) -> bool {
use gunnar_proto::event::Payload;
match event.payload.as_ref() {
Some(Payload::StreamOpened(_)) | Some(Payload::Gap(_)) => return true,
_ => {}
}
let Some(path) = event.repo_path() else {
return matches!(
event.payload.as_ref(),
Some(Payload::KeyUsed(used)) if used.principal_id == caller.principal_id
);
};
let inside = ctx
.control
.may(
&caller.principal_id,
&Access::namespace(&path.namespace),
GrantLevel::Read,
)
.unwrap_or(false);
if inside {
return true;
}
let public = ctx
.control
.namespace_for_outsider(&caller.principal_id, &path.namespace)
.ok()
.flatten()
.is_some_and(|ns| ns.events_public);
public
&& ctx
.control
.may(&caller.principal_id, &Access::repo(path), GrantLevel::Read)
.unwrap_or(false)
}
}
impl Stream for Revalidated {
type Item = Result<Event, Status>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
loop {
if this.refused {
return Poll::Ready(None);
}
let next = match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(item)) => item,
other => return other,
};
if Instant::now() >= this.next_check {
let rechecked = match &this.scope {
WatchScope::Server => this
.ctx
.require(
&this.caller,
Access::Server,
GrantLevel::Read,
"Events.Watch",
)
.map(|_| WatchScope::Server),
WatchScope::Mine => WatchScope::decide(&this.ctx, &this.caller, &[]).map(
|decided| match decided {
WatchScope::Server => WatchScope::Mine,
mine => mine,
},
),
WatchScope::Accounts { names, .. } => {
WatchScope::decide(&this.ctx, &this.caller, names).map(|decided| {
match decided {
WatchScope::Server => WatchScope::Accounts {
names: names.clone(),
outside: Vec::new(),
},
accounts => accounts,
}
})
}
};
match rechecked {
Ok(scope) => this.scope = scope,
Err(status) => {
tracing::info!(
principal = %this.caller.principal_id,
"closing an Events.Watch stream: its subscriber may no longer watch"
);
this.refused = true;
return Poll::Ready(Some(Err(status)));
}
}
this.next_check = Instant::now() + this.recheck;
}
match &next {
Ok(event) if !this.scope.admits(&this.ctx, &this.caller, event) => continue,
_ => return Poll::Ready(Some(next)),
}
}
}
}
#[tonic::async_trait]
impl Events for EventsService {
type WatchStream = Pin<Box<dyn Stream<Item = Result<Event, Status>> + Send + 'static>>;
async fn watch(
&self,
request: Request<WatchRequest>,
) -> Result<Response<Self::WatchStream>, Status> {
let caller = self.ctx.caller(&request)?;
let scope = WatchScope::decide(&self.ctx, &caller, &request.get_ref().namespaces)?;
let request = request.into_inner();
if let WatchScope::Accounts { names, .. } = &scope {
if let Some(stray) = request.repos.iter().find(|r| !names.contains(&r.namespace)) {
return Err(not_shown(&stray.namespace));
}
}
for repo in &request.repos {
repo.validate()
.map_err(|e| Status::invalid_argument(format!("`repos`: {e}")))?;
}
for kind in &request.kinds {
gunnar_proto::EventKind::try_from(*kind)
.map_err(|_| Status::invalid_argument(format!("unknown event kind {kind}")))?;
}
let filter = EventFilter::from_request(&request);
tracing::debug!(
namespaces = filter.namespaces.len(),
repos = filter.repos.len(),
kinds = filter.kinds.len(),
from_seq = request.from_seq,
"new Events.Watch subscriber"
);
Ok(Response::new(Box::pin(Revalidated {
inner: self.ctx.bus.subscribe_from(filter, request.from_seq),
ctx: self.ctx.clone(),
caller,
scope,
next_check: Instant::now(),
recheck: RECHECK,
refused: false,
})))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seam::MemoryControlPlane;
use crate::{EventBus, ServerConfig};
use gunnar_proto::{event, RepoDeleted, RepoPath};
use std::sync::Arc;
use tokio_stream::StreamExt as _;
fn deleted(namespace: &str, repo: &str) -> event::Payload {
event::Payload::RepoDeleted(RepoDeleted {
repo: Some(RepoPath::new(namespace, repo)),
principal_id: String::new(),
})
}
fn plane_with_a_watcher() -> (Arc<MemoryControlPlane>, String) {
use crate::seam::ControlPlane as _;
let plane = MemoryControlPlane::new();
let principal = plane
.add_principal("watcher", "watcher", gunnar_proto::PrincipalKind::User)
.expect("enrol the watcher");
let plane = Arc::new(plane);
plane.grant_server(&principal.id, GrantLevel::Read);
(plane, principal.id)
}
fn caller(principal_id: &str) -> Caller {
Caller {
principal_id: principal_id.to_owned(),
fingerprint: "SHA256:watcher".to_owned(),
machine: false,
authenticated: true,
}
}
fn watching(ctx: &ServiceContext, principal_id: &str) -> Revalidated {
Revalidated {
inner: ctx.bus.subscribe_from(EventFilter::default(), 0),
ctx: ctx.clone(),
caller: caller(principal_id),
scope: WatchScope::Server,
next_check: Instant::now(),
recheck: Duration::ZERO,
refused: false,
}
}
async fn next_delivery(stream: &mut Revalidated) -> Result<Event, Status> {
loop {
match stream.next().await {
Some(Ok(event)) if event.kind == gunnar_proto::EventKind::StreamOpened as i32 => {
continue
}
Some(item) => return item,
None => panic!("the stream ended without delivering or refusing"),
}
}
}
#[tokio::test]
async fn a_watcher_who_may_no_longer_watch_stops_being_handed_events() {
let (plane, watcher) = plane_with_a_watcher();
let ctx = ServiceContext::new(
plane.clone(),
Arc::new(EventBus::new(64)),
ServerConfig::default(),
);
let mut stream = watching(&ctx, &watcher);
ctx.bus.publish(deleted("alpha", "one"));
let event = next_delivery(&mut stream)
.await
.expect("a watcher who may watch is handed the event");
assert_eq!(
event.kind,
gunnar_proto::EventKind::RepoDeleted as i32,
"the fixture delivered something other than the published event"
);
plane.force_may(Some(false));
ctx.bus.publish(deleted("alpha", "two"));
let status = next_delivery(&mut stream)
.await
.expect_err("the stream kept delivering to a subscriber who may no longer watch");
assert_eq!(
status.code(),
tonic::Code::PermissionDenied,
"the stream ended, but not by refusing: {status:?}"
);
assert!(
stream.next().await.is_none(),
"a refused Events.Watch stream handed out something after its refusal"
);
}
#[tokio::test]
async fn a_watcher_who_still_may_keeps_being_handed_events() {
let (plane, watcher) = plane_with_a_watcher();
let ctx = ServiceContext::new(
plane.clone(),
Arc::new(EventBus::new(64)),
ServerConfig::default(),
);
let mut stream = watching(&ctx, &watcher);
for n in 0..4 {
ctx.bus.publish(deleted("alpha", &format!("repo-{n}")));
next_delivery(&mut stream)
.await
.unwrap_or_else(|status| panic!("delivery {n} was refused: {status:?}"));
}
}
}