gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
//! `Events.Watch`, the server-streaming feed (the event stream).
//!
//! Almost the whole implementation is "build a filter, subscribe to the bus,
//! box the stream". Everything interesting (bounded memory, the gap marker for
//! a lagging subscriber, the `StreamOpened` prologue and the `from_seq` replay)
//! lives in [`crate::bus`], because that behaviour has to be testable without a
//! gRPC connection in the way.
//!
//! # The exception: a stream outlives the request that opened it
//!
//! Every other RPC in the control plane is a request, a check and an answer, so
//! "authorise in the handler" is the whole of it. This one is a request, a
//! check, and then an open pipe that keeps delivering for as long as the client
//! keeps reading — hours, in the console's case. `caller`/`require` ran once,
//! when the pipe was opened, and after that the stream carried every event on
//! the server to whoever had once held a server-wide `Read`. Revoking the key,
//! deleting the principal, dropping the grant: all of them took effect for the
//! NEXT `Watch` and for none of the open ones.
//!
//! Nothing gunnar had would close it either. `Watch` is server-scoped on
//! purpose (see the handler), so this is the broadest read in the control plane
//! — pushes, deletions, repository names across every namespace, including the
//! ones the ex-subscriber never had access to.
//!
//! [`Revalidated`] is the answer, and the property it enforces is about DATA
//! rather than about time: **no event is handed to a client across a check
//! older than [`RECHECK`]**. See its own doc for why that is the shape rather
//! than a timer.

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;

/// **How stale the authorisation behind a delivered event may be.**
///
/// Thirty seconds. The cost of the check is one ACL read against a control
/// plane the same process is already reading on every RPC, and it is paid at
/// most twice a minute per subscriber, so the number could be smaller; it is
/// not, because a revocation that takes half a minute to reach an open pipe is
/// indistinguishable from one that takes no time at all to anybody who is not
/// holding a stopwatch, and a per-event check on a hot bus is a read per event.
pub const RECHECK: Duration = Duration::from_secs(30);

/// A `Watch` stream that keeps asking whether its subscriber may still watch.
///
/// # Why the check hangs off delivery and not off a timer
///
/// A timer would need a wakeup of its own, and would then be racing the thing
/// it guards: a revoked subscriber on a busy bus could be handed a hundred
/// events between the revocation and the tick. Checking on the delivery path
/// instead makes the guarantee a statement about what crosses the wire, which
/// is what the finding is about — **no event reaches the client unless an
/// authorisation no older than [`RECHECK`] said it could**. A quiet stream is
/// not re-checked and does not need to be: it is delivering nothing.
///
/// A failed check ends the stream, it does not skip an event. A subscriber
/// whose grant is gone must be told, in the status it was refused with, rather
/// than left holding a pipe that has silently stopped carrying — which is what
/// `EventFilter` validation up in the handler already refuses to do for a
/// mistyped namespace, for the same reason.
struct Revalidated {
    inner: crate::bus::EventStream,
    ctx: ServiceContext,
    caller: Caller,
    /// What this subscriber was let in on, re-asked at every check.
    scope: WatchScope,
    /// When the next delivery must re-check. Set to now at construction, so the
    /// FIRST event out of a long replay is covered too.
    next_check: Instant,
    /// [`RECHECK`], as a field so the constant lives in one place and a test
    /// can ask for a check on every delivery rather than sleeping for half a
    /// minute to observe one.
    recheck: Duration,
    /// Set once the check has failed. A `Stream` must not be polled for more
    /// after it has yielded its terminal error, and the refusal is terminal.
    refused: bool,
}

/// **What a watcher was let in on** (owner, 2026-10-03: an account's emitted
/// events are not public by default, "such things has to be set").
#[derive(Debug, Clone, PartialEq, Eq)]
enum WatchScope {
    /// A server reader: every event its filter lets through, as always.
    Server,
    /// Named accounts and nothing else — the request's `namespaces`, which for
    /// this watcher are a BOUNDARY, not a filter. `outside` are the ones it is
    /// not inside (watched because their events are public): an event about a
    /// repository there reaches it only when it may read that repository, so a
    /// private repository's events never leave the account.
    Accounts {
        names: Vec<String>,
        outside: Vec<String>,
    },
    /// **The caller's own** (2026-10-04, the thin layer): a non-server reader
    /// that named NO account. It cannot name the accounts it is inside — that
    /// is gunnar's to know — so the boundary is decided per EVENT: an event
    /// about a repository reaches it when the caller is inside that account
    /// (`Read` over its namespace, gates honoured), or when the account's
    /// events are public and the caller may read that repository. The
    /// stream's own bookkeeping (`StreamOpened`, `EventGap`) reaches it; an
    /// event about no repository reaches it only when it is a `KeyUsed` of the
    /// caller's own principal.
    Mine,
}

/// The one sentence for an account whose events are not shown to this caller —
/// the same whether the account exists or not, so a refusal tells an outsider
/// nothing about which names are taken.
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 {
    /// Decide (or re-decide) a watch. `Server` when the caller holds a
    /// server-wide `Read`; otherwise every named account must be one the caller
    /// is inside (`Read` over its namespace) or one whose events are public.
    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),
            // Naming no account: the caller's own. Let in on the caller's own
            // account (`Read` over the namespace named after it) — which is
            // true for any principal that exists and is not locked out — so a
            // removed or gated principal is refused here, in gunnar's words,
            // and the same question is the re-check below. A MEMBER has no
            // account of his own (2026-10-07): his is the account that
            // enrolled him, read on the `Read` enrolment gave him.
            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,
        })
    }

    /// Is `event` one this watcher may be handed? A server reader: yes. An
    /// account watcher: yes, unless it is about a repository in an account it
    /// is outside of and may not read.
    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 {
    /// **A server reader's per-event boundary** (2026-10-05, the operator
    /// sees aggregates only): everything, except what happens inside a
    /// customer's account its server grant does not reach — that it is shown
    /// as any outsider is, only when the account admin made the events public.
    /// A lookup that fails is a `false`.
    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,
            },
        }
    }

    /// [`WatchScope::Mine`]'s per-event boundary. A lookup that fails is a
    /// `false`: an event withheld is recoverable, one leaked is not.
    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);
            }
            // Poll first: a stream with nothing to hand over has nothing to check
            // an authorisation FOR, and re-checking a parked subscriber twice a
            // minute would be a control-plane read per idle console.
            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),
                    // The caller is still a principal here and not locked out
                    // — the question it was let in on. What each event may
                    // carry is decided per event, so a grant revoked
                    // mid-stream already stops the next delivery.
                    WatchScope::Mine => WatchScope::decide(&this.ctx, &this.caller, &[]).map(
                        |decided| match decided {
                            // A server grant arriving mid-stream does not
                            // widen an own watch either.
                            WatchScope::Server => WatchScope::Mine,
                            mine => mine,
                        },
                    ),
                    WatchScope::Accounts { names, .. } => {
                        WatchScope::decide(&this.ctx, &this.caller, names).map(|decided| {
                            match decided {
                                // A grant arriving mid-stream does not widen an
                                // account watcher: its boundary is what it asked
                                // for (and a server reader may read every
                                // repository, so nothing is filtered away).
                                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> {
        // **A server reader watches everything** (its `repos`/`namespaces` are
        // a FILTER). **Anybody else watches named accounts only**, and for them
        // the `namespaces` are the BOUNDARY: each must be an account the caller
        // is inside, or one whose account admin made its events public — and
        // even then a private repository's events stay inside its account
        // ([`WatchScope`]). An account's events are shown to nobody outside it
        // by default (owner, 2026-10-03).
        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 {
            // A repository filter outside the named accounts would widen the
            // boundary through the other axis.
            if let Some(stray) = request.repos.iter().find(|r| !names.contains(&r.namespace)) {
                return Err(not_shown(&stray.namespace));
            }
        }

        // Validate every path in the filter rather than silently watching
        // nothing: a UI that mistyped a namespace should get an error, not an
        // eternally quiet stream it will read as "no activity".
        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"
        );

        // `subscribe_from`, not `subscribe`: every Watch stream opens with the
        // `StreamOpened` prologue, whether or not a cursor was sent. A client
        // that starts with no cursor is the one that most needs to be told
        // which epoch its first sequence number belongs to — it is going to
        // reconnect eventually, and by then the answer would be gone.
        // Wrapped, not returned bare: see [`Revalidated`]. `next_check` is
        // `now`, so even the first event out of a `from_seq` replay crosses a
        // fresh check rather than riding the one the handler just made — the
        // replay can be long, and "authorised when you asked" is precisely the
        // guarantee that was not enough.
        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(),
        })
    }

    /// A control plane holding one enrolled principal with a server-wide
    /// `Read` — the standing this RPC needs, and the standing the test takes
    /// away.
    fn plane_with_a_watcher() -> (Arc<MemoryControlPlane>, String) {
        use crate::seam::ControlPlane as _;
        let plane = MemoryControlPlane::new();
        // The principal has to be ENROLLED: `may` returns false for an id it
        // does not know, so granting without adding would make both tests below
        // pass for the wrong reason.
        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,
        }
    }

    /// A stream that re-checks on every delivery, so a test can watch a
    /// revocation take effect without sleeping out [`RECHECK`].
    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,
        }
    }

    /// Pull items until one is an event carrying a payload, and return whether
    /// it arrived or the stream refused.
    ///
    /// The prologue (`StreamOpened`) is an event too, and skipping it by name
    /// rather than by counting keeps the assertions about the thing under test.
    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"),
            }
        }
    }

    /// **A watcher whose authorisation is withdrawn stops being handed
    /// events.**
    ///
    /// `Events.Watch` is the one RPC in the control plane whose answer outlives
    /// its request: `caller`/`require` ran when the pipe opened and the pipe
    /// then carried every event on the server for as long as the client kept
    /// reading. And it is the BROADEST read there is — the stream is
    /// server-scoped on purpose, so it carries pushes, deletions and repository
    /// names across every namespace, including ones the ex-subscriber never had
    /// access to.
    ///
    /// Both halves, because a stream that refused everything would satisfy the
    /// second on its own: an event published while the grant holds is
    /// DELIVERED, and one published after it is withdrawn is refused with a
    /// status rather than silently dropped — a pipe that has quietly stopped
    /// carrying reads as "no activity", which is the same failure the handler's
    /// filter validation refuses for a mistyped namespace.
    #[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"
        );

        // The grant goes. Nothing else changes — the stream is open, the bus is
        // the same bus, and the subscriber is still reading.
        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:?}"
        );

        // Terminal: a refused stream must not be pollable for more. Without
        // this the refusal could be one skipped event rather than a closed
        // pipe, and the next event would flow.
        assert!(
            stream.next().await.is_none(),
            "a refused Events.Watch stream handed out something after its refusal"
        );
    }

    /// The identity-value guard for the test above.
    ///
    /// A `Revalidated` that refused on every delivery would satisfy the refusal
    /// half perfectly. This drives four events through a subscriber whose grant
    /// never changes and requires all four.
    #[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:?}"));
        }
    }
}