use cyberbrain_core::{Error, Result, Ring};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Send,
Receive,
Both,
}
impl Direction {
pub fn parse(s: &str) -> Result<Self> {
match s {
"send" => Ok(Direction::Send),
"receive" => Ok(Direction::Receive),
"both" => Ok(Direction::Both),
other => Err(Error::Config(format!(
"direction {other:?} does not exist; use send, receive or both"
))),
}
}
pub fn as_str(self) -> &'static str {
match self {
Direction::Send => "send",
Direction::Receive => "receive",
Direction::Both => "both",
}
}
fn covers(self, wanted: Direction) -> bool {
self == Direction::Both || self == wanted
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BereichGrant {
pub id: String,
pub device: String,
pub bereich: String,
pub direction: Direction,
pub reason: String,
pub granted_by: String,
pub created_at: String,
pub approved_by: Option<String>,
pub approved_at: Option<String>,
pub revoked_at: Option<String>,
}
impl BereichGrant {
pub fn is_active(&self) -> bool {
self.revoked_at.is_none()
}
pub fn is_effective(&self) -> bool {
self.is_active() && self.approved_at.is_some()
}
pub fn is_pending(&self) -> bool {
self.is_active() && self.approved_at.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "denied", rename_all = "kebab-case")]
pub enum SyncDenied {
RingNeverLeaves { ring: u8 },
NoteHasNoBereich,
NoGrant { device: String, bereich: String },
WrongDirection {
device: String,
bereich: String,
granted: Direction,
wanted: Direction,
},
Revoked {
device: String,
bereich: String,
at: String,
},
AwaitingCountersignature {
device: String,
bereich: String,
id: String,
granted_by: String,
},
}
impl SyncDenied {
pub fn line(&self) -> String {
match self {
SyncDenied::RingNeverLeaves { ring } => format!(
"ring {ring} never leaves the machine: invariants and protocol belong to the \
operator of each host, and no grant can permit them"
),
SyncDenied::NoteHasNoBereich => {
"the note carries no bereich, so no grant can apply to it; set one with \
`--bereich` before it can be shared"
.into()
}
SyncDenied::NoGrant { device, bereich } => format!(
"device {device} has no grant for bereich {bereich}: \
`cyberbrain hub grant --device {device} --bereich {bereich} \
--direction … --reason …`"
),
SyncDenied::WrongDirection {
device,
bereich,
granted,
wanted,
} => format!(
"device {device} may {} bereich {bereich}, not {}",
granted.as_str(),
wanted.as_str()
),
SyncDenied::Revoked {
device,
bereich,
at,
} => format!("the grant of {bereich} to device {device} was withdrawn at {at}"),
SyncDenied::AwaitingCountersignature {
device,
bereich,
id,
granted_by,
} => format!(
concat!(
"the grant of {b} to device {d} was written by {g} and nobody has ",
"countersigned it: a bereich takes two people, so that whoever runs ",
"the hub cannot point one at a machine of their own. Somebody holding ",
"a countersigner credential runs `cyberbrain hub grant approve {i} ",
"--as <credential>`"
),
b = bereich,
d = device,
g = granted_by,
i = id
),
}
}
}
pub fn may_move(
device: &str,
ring: Ring,
bereich: Option<&str>,
wanted: Direction,
grants: &[BereichGrant],
) -> std::result::Result<(), SyncDenied> {
if matches!(ring, Ring::Invariant | Ring::Protocol) {
return Err(SyncDenied::RingNeverLeaves { ring: ring.as_u8() });
}
let Some(bereich) = bereich else {
return Err(SyncDenied::NoteHasNoBereich);
};
let mine: Vec<&BereichGrant> = grants
.iter()
.filter(|g| g.device == device && g.bereich == bereich)
.collect();
if mine.is_empty() {
return Err(SyncDenied::NoGrant {
device: device.into(),
bereich: bereich.into(),
});
}
if let Some(g) = mine
.iter()
.find(|g| g.is_effective() && g.direction.covers(wanted))
{
let _ = g;
return Ok(());
}
if let Some(g) = mine
.iter()
.find(|g| g.is_effective() && !g.direction.covers(wanted))
{
return Err(SyncDenied::WrongDirection {
device: device.into(),
bereich: bereich.into(),
granted: g.direction,
wanted,
});
}
if let Some(g) = mine.iter().find(|g| g.is_pending()) {
return Err(SyncDenied::AwaitingCountersignature {
device: device.into(),
bereich: bereich.into(),
id: g.id.clone(),
granted_by: g.granted_by.clone(),
});
}
let newest = mine
.iter()
.filter_map(|g| g.revoked_at.clone())
.max()
.unwrap_or_else(|| "an unknown time".into());
Err(SyncDenied::Revoked {
device: device.into(),
bereich: bereich.into(),
at: newest,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn grant(device: &str, bereich: &str, dir: Direction) -> BereichGrant {
BereichGrant {
approved_by: Some("council-1".into()),
approved_at: Some("2026-09-10T12:05:00Z".into()),
..pending(device, bereich, dir)
}
}
fn pending(device: &str, bereich: &str, dir: Direction) -> BereichGrant {
BereichGrant {
id: format!("g-{device}-{bereich}"),
device: device.into(),
bereich: bereich.into(),
direction: dir,
reason: "Schichtuebergabe innerhalb der Abteilung".into(),
granted_by: "admin-1".into(),
created_at: "2026-09-10T12:00:00Z".into(),
approved_by: None,
approved_at: None,
revoked_at: None,
}
}
#[test]
fn a_grant_nobody_countersigned_moves_nothing() {
let written = vec![pending("laptop-a", "disposition", Direction::Both)];
let denied = may_move(
"laptop-a",
Ring::Knowledge,
Some("disposition"),
Direction::Send,
&written,
)
.expect_err("a grant nobody signed must not move a note");
assert!(
matches!(denied, SyncDenied::AwaitingCountersignature { .. }),
"{denied:?}"
);
let line = denied.line();
assert!(line.contains("hub grant approve"), "{line}");
assert!(line.contains("g-laptop-a-disposition"), "{line}");
assert!(
line.contains("admin-1"),
"who wrote it belongs in the line: {line}"
);
let signed = vec![grant("laptop-a", "disposition", Direction::Both)];
assert!(
may_move(
"laptop-a",
Ring::Knowledge,
Some("disposition"),
Direction::Send,
&signed
)
.is_ok()
);
}
#[test]
fn no_grant_can_send_ring_0_or_1() {
let generous = vec![
grant("laptop-a", "disposition", Direction::Both),
grant("laptop-a", "hr", Direction::Both),
];
for ring in [Ring::Invariant, Ring::Protocol] {
let r = may_move(
"laptop-a",
ring,
Some("disposition"),
Direction::Send,
&generous,
);
assert_eq!(
r,
Err(SyncDenied::RingNeverLeaves { ring: ring.as_u8() }),
"ring {ring:?} was allowed to leave"
);
}
for ring in [Ring::Knowledge, Ring::Session, Ring::External] {
assert!(
may_move(
"laptop-a",
ring,
Some("disposition"),
Direction::Send,
&generous
)
.is_ok(),
"ring {ring:?} should be eligible"
);
}
}
#[test]
fn disposition_reaches_disposition_but_not_hr() {
let grants = vec![
grant("mitarbeiter-1", "disposition", Direction::Both),
grant("mitarbeiter-2", "disposition", Direction::Both),
grant("mitarbeiter-3", "hr", Direction::Both),
];
assert!(
may_move(
"mitarbeiter-1",
Ring::Knowledge,
Some("disposition"),
Direction::Send,
&grants
)
.is_ok()
);
assert!(
may_move(
"mitarbeiter-2",
Ring::Knowledge,
Some("disposition"),
Direction::Receive,
&grants
)
.is_ok()
);
assert_eq!(
may_move(
"mitarbeiter-3",
Ring::Knowledge,
Some("disposition"),
Direction::Receive,
&grants
),
Err(SyncDenied::NoGrant {
device: "mitarbeiter-3".into(),
bereich: "disposition".into(),
})
);
}
#[test]
fn a_note_without_a_bereich_never_moves() {
let grants = vec![grant("laptop-a", "disposition", Direction::Both)];
assert_eq!(
may_move("laptop-a", Ring::Knowledge, None, Direction::Send, &grants),
Err(SyncDenied::NoteHasNoBereich)
);
}
#[test]
fn a_send_grant_does_not_let_anything_in() {
let grants = vec![grant("laptop-a", "disposition", Direction::Send)];
assert!(
may_move(
"laptop-a",
Ring::Knowledge,
Some("disposition"),
Direction::Send,
&grants
)
.is_ok()
);
assert_eq!(
may_move(
"laptop-a",
Ring::Knowledge,
Some("disposition"),
Direction::Receive,
&grants
),
Err(SyncDenied::WrongDirection {
device: "laptop-a".into(),
bereich: "disposition".into(),
granted: Direction::Send,
wanted: Direction::Receive,
})
);
}
#[test]
fn a_withdrawn_grant_says_so() {
let mut g = grant("laptop-a", "disposition", Direction::Both);
g.revoked_at = Some("2026-09-10T13:00:00Z".into());
let r = may_move(
"laptop-a",
Ring::Knowledge,
Some("disposition"),
Direction::Send,
&[g],
);
assert_eq!(
r,
Err(SyncDenied::Revoked {
device: "laptop-a".into(),
bereich: "disposition".into(),
at: "2026-09-10T13:00:00Z".into(),
})
);
assert!(r.unwrap_err().line().contains("withdrawn"));
}
#[test]
fn a_live_grant_wins_over_a_revoked_one() {
let mut old = grant("laptop-a", "disposition", Direction::Both);
old.revoked_at = Some("2026-09-01T00:00:00Z".into());
let new = grant("laptop-a", "disposition", Direction::Both);
assert!(
may_move(
"laptop-a",
Ring::Knowledge,
Some("disposition"),
Direction::Send,
&[old, new]
)
.is_ok()
);
}
#[test]
fn every_refusal_is_actionable() {
let cases = [
SyncDenied::RingNeverLeaves { ring: 0 },
SyncDenied::NoteHasNoBereich,
SyncDenied::NoGrant {
device: "d".into(),
bereich: "b".into(),
},
SyncDenied::WrongDirection {
device: "d".into(),
bereich: "b".into(),
granted: Direction::Send,
wanted: Direction::Receive,
},
SyncDenied::Revoked {
device: "d".into(),
bereich: "b".into(),
at: "t".into(),
},
];
for c in cases {
let l = c.line();
assert!(l.len() > 30, "refusal too terse: {l}");
assert!(!l.ends_with("refused."), "refusal says nothing useful: {l}");
}
}
}