use serde::{Deserialize, Serialize};
use crate::admission::AdmissionPolicy;
use crate::artifact::SignedArtifact;
use crate::lease::Lease;
use crate::principal::PrincipalId;
use crate::revocation::RevocationKey;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceRevocationKey {
pub kind: String,
pub id: String,
pub key: RevocationKey,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotMember {
pub principal: PrincipalId,
pub relation: String,
pub via: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lease: Option<Lease>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetSnapshot {
pub issuer: String,
pub kind: String,
pub id: String,
pub epoch: u64,
pub members: Vec<SnapshotMember>,
pub revocation_keys: Vec<ResourceRevocationKey>,
pub iat: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<AdmissionPolicy>,
#[serde(flatten)]
pub lease: Lease,
}
impl SetSnapshot {
#[allow(clippy::too_many_arguments)]
pub fn new(
issuer: impl Into<String>,
kind: impl Into<String>,
id: impl Into<String>,
epoch: u64,
mut members: Vec<SnapshotMember>,
mut revocation_keys: Vec<ResourceRevocationKey>,
iat: i64,
lease: Lease,
) -> Self {
for m in &mut members {
m.via.sort();
m.via.dedup();
}
members.sort_by(|a, b| (&a.principal, &a.relation).cmp(&(&b.principal, &b.relation)));
revocation_keys.sort_by(|a, b| (&a.kind, &a.id).cmp(&(&b.kind, &b.id)));
revocation_keys.dedup_by(|later, kept| later.kind == kept.kind && later.id == kept.id);
Self {
issuer: issuer.into(),
kind: kind.into(),
id: id.into(),
epoch,
members,
revocation_keys,
iat,
policy: None,
lease,
}
}
pub fn with_policy(mut self, policy: Option<AdmissionPolicy>) -> Self {
self.policy = policy;
self
}
pub fn lists(&self, principal: &PrincipalId, relation: &str) -> bool {
self.members.iter().any(|m| &m.principal == principal && m.relation == relation)
}
pub fn revocation_key(&self, kind: &str, id: &str) -> Option<&RevocationKey> {
self.revocation_keys
.binary_search_by(|k| (k.kind.as_str(), k.id.as_str()).cmp(&(kind, id)))
.ok()
.map(|i| &self.revocation_keys[i].key)
}
}
impl SignedArtifact for SetSnapshot {
const IMPLICIT_ASSERTION: &'static [u8] = b"urn:cheers:artifact:set-snapshot:v1";
fn issuer(&self) -> &str {
&self.issuer
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lease::LeaseState;
use crate::{RevocationSet, StandingBinding};
fn member(user: &str, relation: &str, via: &[(&str, &str)]) -> SnapshotMember {
SnapshotMember {
principal: PrincipalId::user(user),
relation: relation.into(),
via: via.iter().map(|(k, i)| ((*k).into(), (*i).into())).collect(),
lease: None,
}
}
fn key(kind: &str, id: &str, b: u8) -> ResourceRevocationKey {
ResourceRevocationKey { kind: kind.into(), id: id.into(), key: RevocationKey::from_bytes([b; 32]) }
}
fn snapshot() -> SetSnapshot {
SetSnapshot::new(
"https://c.example",
"namespace",
"ed",
42,
vec![
member("bob", "member", &[("namespace", "ed")]),
member("alice", "member", &[("namespace", "ed")]),
member("alice", "guest", &[("namespace", "ed"), ("namespace", "museum")]),
],
vec![key("namespace", "museum", 2), key("namespace", "ed", 1)],
1_000,
Lease::new(1_000, 2_000, None).unwrap(),
)
}
#[test]
fn wire_shape_has_no_exp_and_round_trips() {
let v = serde_json::to_value(snapshot()).unwrap();
assert_eq!(
v,
serde_json::json!({
"issuer": "https://c.example",
"kind": "namespace",
"id": "ed",
"epoch": 42,
"members": [
{"principal": "user:alice", "relation": "guest", "via": [["namespace", "ed"], ["namespace", "museum"]]},
{"principal": "user:alice", "relation": "member", "via": [["namespace", "ed"]]},
{"principal": "user:bob", "relation": "member", "via": [["namespace", "ed"]]},
],
"revocation_keys": [
{"kind": "namespace", "id": "ed", "key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"},
{"kind": "namespace", "id": "museum", "key": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI"},
],
"iat": 1_000,
"refresh_after": 2_000,
})
);
let back: SetSnapshot = serde_json::from_value(v).unwrap();
assert_eq!(back, snapshot());
}
#[test]
fn new_sorts_members_and_via_deterministically() {
let a = snapshot();
let mut shuffled = a.members.clone();
shuffled.reverse();
shuffled[0].via.reverse();
let mut keys = a.revocation_keys.clone();
keys.reverse();
let b = SetSnapshot::new(&a.issuer, &a.kind, &a.id, a.epoch, shuffled, keys, a.iat, a.lease);
assert_eq!(serde_json::to_vec(&a).unwrap(), serde_json::to_vec(&b).unwrap());
assert!(a.lists(&PrincipalId::user("alice"), "guest"));
assert!(!a.lists(&PrincipalId::user("bob"), "guest"));
assert_eq!(a.revocation_key("namespace", "museum"), Some(&RevocationKey::from_bytes([2; 32])));
assert_eq!(a.revocation_key("namespace", "nope"), None);
}
#[test]
fn refresh_after_is_advisory() {
let s = snapshot();
assert_eq!(s.lease.state_at(1_999), LeaseState::Current);
assert_eq!(s.lease.state_at(2_000), LeaseState::Warning { exp: None });
}
#[test]
fn key_principal_is_a_member_in_wire_form() {
let k = PrincipalId::from_public_key(&[9; 32]);
let mut members = snapshot().members;
members.push(SnapshotMember { principal: k.clone(), relation: "guest".into(), via: vec![("namespace".into(), "ed".into())], lease: None });
let s = SetSnapshot::new("https://c.example", "namespace", "ed", 42, members, vec![key("namespace", "ed", 1)], 1_000, Lease::new(1_000, 2_000, None).unwrap());
assert!(s.lists(&k, "guest"));
assert!(!s.lists(&PrincipalId::user(k.id.clone()), "guest"));
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["members"][3]["principal"], serde_json::json!(k.to_string()));
assert_eq!(serde_json::from_value::<SetSnapshot>(v).unwrap(), s);
}
const GOLDEN: &str = r#"{"issuer":"https://c.example","kind":"namespace","id":"ed","epoch":42,"members":[],"revocation_keys":[],"iat":1000,"refresh_after":2000}"#;
#[test]
fn flattened_lease_is_byte_identical_to_the_old_wire() {
let s = SetSnapshot::new("https://c.example", "namespace", "ed", 42, vec![], vec![], 1_000, Lease::new(1_000, 2_000, None).unwrap());
assert_eq!(serde_json::to_string(&s).unwrap(), GOLDEN);
let back: SetSnapshot = serde_json::from_str(GOLDEN).unwrap();
assert_eq!(back, s);
assert_eq!(serde_json::to_string(&back).unwrap(), GOLDEN);
}
#[test]
fn exp_rides_the_flattened_lease() {
let lease = Lease::new(1_000, 1_500, Some(2_000)).unwrap();
let s = SetSnapshot::new("i", "namespace", "ed", 1, vec![], vec![], 1_000, lease);
let json = serde_json::to_string(&s).unwrap();
assert!(json.ends_with(r#""iat":1000,"refresh_after":1500,"exp":2000}"#), "{json}");
assert_eq!(serde_json::from_str::<SetSnapshot>(&json).unwrap(), s);
}
#[test]
fn policy_rides_the_wire_only_when_present() {
use crate::admission::AdmissionMode;
let s = SetSnapshot::new("https://c.example", "namespace", "ed", 42, vec![], vec![], 1_000, Lease::new(1_000, 2_000, None).unwrap());
assert_eq!(serde_json::to_string(&s.clone().with_policy(None)).unwrap(), GOLDEN);
let p = AdmissionPolicy::new(AdmissionMode::Knock, "guest");
let with = s.with_policy(Some(p.clone()));
let json = serde_json::to_string(&with).unwrap();
assert!(json.contains(r#""policy":{"mode":"knock","floor":"guest""#), "{json}");
let back: SetSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.policy, Some(p));
assert_eq!(back, with);
}
#[test]
fn a_member_lease_rides_the_wire_only_when_present() {
let golden = r#"{"principal":"user:alice","relation":"guest","via":[["namespace","ed"]]}"#;
let m = member("alice", "guest", &[("namespace", "ed")]);
assert_eq!(serde_json::to_string(&m).unwrap(), golden);
assert_eq!(serde_json::from_str::<SnapshotMember>(golden).unwrap(), m);
let leased = SnapshotMember { lease: Some(Lease::new(1_000, 1_500, Some(2_000)).unwrap()), ..m };
let json = serde_json::to_string(&leased).unwrap();
assert!(json.ends_with(r#""lease":{"refresh_after":1500,"exp":2000}}"#), "{json}");
assert_eq!(serde_json::from_str::<SnapshotMember>(&json).unwrap(), leased);
}
#[test]
fn implicit_assertion_is_its_own() {
assert_ne!(SetSnapshot::IMPLICIT_ASSERTION, RevocationSet::IMPLICIT_ASSERTION);
assert_ne!(SetSnapshot::IMPLICIT_ASSERTION, StandingBinding::IMPLICIT_ASSERTION);
}
}