1pub mod civil;
44pub mod signing;
45
46use std::collections::BTreeMap;
47use std::sync::Arc;
48
49use arc_swap::ArcSwap;
50pub use ed25519_dalek::VerifyingKey;
54pub use monetize_product::{EntitlementFact, State, TenantId};
55pub use signing::{Snapshot, SignatureError};
56
57#[derive(Clone, Copy, PartialEq, Eq, Debug)]
59pub enum Action {
60 Write,
62 Read,
64 AnonymousRead,
66 Admin,
68}
69
70#[derive(Clone, PartialEq, Eq, Debug)]
71pub enum Verdict {
72 Allow,
73 Refuse {
74 state: State,
75 reason_line: String,
77 url: String,
79 },
80}
81
82impl Verdict {
83 pub fn is_allowed(&self) -> bool {
84 matches!(self, Verdict::Allow)
85 }
86}
87
88#[derive(Debug, thiserror::Error)]
89pub enum RefreshError {
90 #[error("snapshot is not valid JSON: {0}")]
91 Parse(#[from] serde_json::Error),
92 #[error(transparent)]
93 Signature(#[from] SignatureError),
94 #[error("snapshot issued at {offered} is older than the {held} the cache holds")]
95 Stale { offered: u64, held: u64 },
96}
97
98#[derive(Debug, thiserror::Error)]
99#[error("public key is not a valid Ed25519 point")]
100pub struct KeyError;
101
102struct Facts {
103 issued_unix_ms: u64,
104 by_tenant: BTreeMap<TenantId, EntitlementFact>,
105}
106
107pub struct EntitlementCache {
108 key: VerifyingKey,
109 billing_url: String,
111 facts: ArcSwap<Facts>,
112}
113
114impl EntitlementCache {
115 pub fn new(public_key: &[u8; 32], billing_url: &str) -> Result<Self, KeyError> {
118 let key = VerifyingKey::from_bytes(public_key).map_err(|_| KeyError)?;
119 Ok(Self {
120 key,
121 billing_url: billing_url.to_string(),
122 facts: ArcSwap::from_pointee(Facts { issued_unix_ms: 0, by_tenant: BTreeMap::new() }),
123 })
124 }
125
126 pub fn public_key(&self) -> &VerifyingKey {
127 &self.key
128 }
129
130 pub fn allows(&self, tenant: &TenantId, action: Action) -> Verdict {
132 let facts = self.facts.load();
133 let Some(fact) = facts.by_tenant.get(tenant) else {
134 return Verdict::Allow; };
136 match (fact.state, action) {
137 (State::Free | State::Paid | State::Grace, _) => Verdict::Allow,
138 (State::Suspended | State::Retention, Action::Read) => Verdict::Allow,
139 (state, action) => Verdict::Refuse {
140 state,
141 reason_line: self.reason_line(fact, action),
142 url: self.url_for(tenant),
143 },
144 }
145 }
146
147 pub fn notice(&self, tenant: &TenantId) -> Option<String> {
149 let facts = self.facts.load();
150 let fact = facts.by_tenant.get(tenant)?;
151 if fact.state != State::Grace {
152 return None;
153 }
154 Some(format!("{}; renew at {}", self.expiry_phrase(fact), self.url_for(tenant)))
155 }
156
157 pub fn state(&self, tenant: &TenantId) -> State {
159 self.facts.load().by_tenant.get(tenant).map_or(State::Free, |f| f.state)
160 }
161
162 pub fn fact(&self, tenant: &TenantId) -> Option<EntitlementFact> {
164 self.facts.load().by_tenant.get(tenant).cloned()
165 }
166
167 pub fn issued_unix_ms(&self) -> u64 {
169 self.facts.load().issued_unix_ms
170 }
171
172 pub fn len(&self) -> usize {
173 self.facts.load().by_tenant.len()
174 }
175
176 pub fn is_empty(&self) -> bool {
177 self.len() == 0
178 }
179
180 pub fn refresh(&self, snapshot_bytes: &[u8]) -> Result<usize, RefreshError> {
184 let snap: Snapshot = serde_json::from_slice(snapshot_bytes)?;
185 signing::verify_snapshot(&snap, &self.key)?;
186 let held = self.facts.load().issued_unix_ms;
187 if snap.issued_unix_ms < held {
188 return Err(RefreshError::Stale { offered: snap.issued_unix_ms, held });
189 }
190 let by_tenant: BTreeMap<TenantId, EntitlementFact> =
191 snap.facts.into_iter().map(|f| (f.tenant.clone(), f)).collect();
192 let n = by_tenant.len();
193 self.facts.store(Arc::new(Facts { issued_unix_ms: snap.issued_unix_ms, by_tenant }));
194 Ok(n)
195 }
196
197 pub fn push(&self, fact: EntitlementFact) -> Result<(), SignatureError> {
199 signing::verify_fact(&fact, &self.key)?;
200 let current = self.facts.load_full();
201 let mut by_tenant = current.by_tenant.clone();
202 by_tenant.insert(fact.tenant.clone(), fact);
203 self.facts.store(Arc::new(Facts { issued_unix_ms: current.issued_unix_ms, by_tenant }));
204 Ok(())
205 }
206
207 fn url_for(&self, tenant: &TenantId) -> String {
208 self.billing_url.replace("{tenant}", &tenant.0)
209 }
210
211 fn expiry_phrase(&self, fact: &EntitlementFact) -> String {
215 let what = if fact.plan.is_empty() { "entitlement".to_owned() } else { format!("order {}", fact.plan) };
216 match fact.paid_until_unix_ms {
217 Some(until) => format!("{what} expired {}", civil::iso_date(until)),
218 None => format!("{what} suspended by operator"),
219 }
220 }
221
222 fn reason_line(&self, fact: &EntitlementFact, action: Action) -> String {
223 let url = self.url_for(&fact.tenant);
224 let expiry = self.expiry_phrase(fact);
225 let what = match (fact.state, action) {
226 (State::Retention, Action::Write) => "writes closed, data kept for export",
227 (_, Action::Write) => "writes closed",
228 (_, Action::AnonymousRead) => "anonymous access closed",
229 (_, Action::Admin) => "settings closed",
230 (_, Action::Read) => unreachable!("Read is allowed in every state"),
231 };
232 format!("{expiry}: {what}; renew at {url}")
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use ed25519_dalek::{Signer, SigningKey};
240
241 fn key() -> SigningKey {
242 SigningKey::generate(&mut rand::rngs::OsRng)
243 }
244
245 fn fact(tenant: &str, state: State, paid_until: Option<u64>) -> EntitlementFact {
246 EntitlementFact {
247 tenant: TenantId(tenant.into()),
248 plan: "10gb".into(),
249 state,
250 paid_until_unix_ms: paid_until,
251 caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
252 source: "payment:mock:gunnar/team/sub/2026-09-03".into(),
253 signature: vec![],
254 }
255 }
256
257 fn signed(key: &SigningKey, mut f: EntitlementFact) -> EntitlementFact {
258 f.signature = key.sign(&signing::fact_message(&f)).to_bytes().to_vec();
259 f
260 }
261
262 fn snapshot(key: &SigningKey, issued: u64, facts: Vec<EntitlementFact>) -> Vec<u8> {
263 let signature = key.sign(&signing::snapshot_message(issued, &facts)).to_bytes().to_vec();
264 serde_json::to_vec(&Snapshot { issued_unix_ms: issued, facts, signature }).unwrap()
265 }
266
267 fn cache(key: &SigningKey) -> EntitlementCache {
268 EntitlementCache::new(&key.verifying_key().to_bytes(), "https://gunnar.rs/billing/{tenant}").unwrap()
269 }
270
271 const OCT_1_2026: u64 = 1_790_812_800_000;
272
273 #[test]
274 fn genuine_snapshot_is_accepted_and_answers() {
275 let k = key();
276 let c = cache(&k);
277 let bytes = snapshot(&k, 10, vec![signed(&k, fact("team/sub", State::Suspended, Some(OCT_1_2026)))]);
278 assert_eq!(c.refresh(&bytes).unwrap(), 1);
279 assert_eq!(c.state(&TenantId("team/sub".into())), State::Suspended);
280 assert_eq!(c.issued_unix_ms(), 10);
281 }
282
283 #[test]
284 fn forged_snapshot_is_rejected_and_changes_nothing() {
285 let k = key();
286 let forger = key();
287 let c = cache(&k);
288 let t = TenantId("team/sub".into());
289 let mut forged = signed(&forger, fact("team/sub", State::Paid, Some(OCT_1_2026)));
291 let bytes = snapshot(&k, 10, vec![forged.clone()]);
292 assert!(matches!(c.refresh(&bytes), Err(RefreshError::Signature(SignatureError::Fact(_)))));
293 forged = signed(&k, forged);
295 let bytes = snapshot(&forger, 10, vec![forged.clone()]);
296 assert!(matches!(c.refresh(&bytes), Err(RefreshError::Signature(SignatureError::Envelope))));
297 forged.state = State::Free;
299 assert!(matches!(c.push(forged), Err(SignatureError::Fact(_))));
300 assert!(matches!(c.refresh(b"not json"), Err(RefreshError::Parse(_))));
302 assert!(c.is_empty());
303 assert_eq!(c.state(&t), State::Free);
304 }
305
306 #[test]
307 fn stale_snapshot_is_refused_newer_one_is_taken() {
308 let k = key();
309 let c = cache(&k);
310 c.refresh(&snapshot(&k, 20, vec![])).unwrap();
311 assert!(matches!(c.refresh(&snapshot(&k, 19, vec![])), Err(RefreshError::Stale { offered: 19, held: 20 })));
312 c.refresh(&snapshot(&k, 21, vec![])).unwrap();
313 assert_eq!(c.issued_unix_ms(), 21);
314 }
315
316 #[test]
317 fn push_replaces_one_tenant_and_keeps_the_rest() {
318 let k = key();
319 let c = cache(&k);
320 let a = TenantId("a".into());
321 let b = TenantId("b".into());
322 c.refresh(&snapshot(&k, 1, vec![signed(&k, fact("a", State::Paid, None)), signed(&k, fact("b", State::Paid, None))])).unwrap();
323 c.push(signed(&k, fact("a", State::Suspended, Some(OCT_1_2026)))).unwrap();
324 assert_eq!(c.state(&a), State::Suspended);
325 assert_eq!(c.state(&b), State::Paid);
326 assert_eq!(c.len(), 2);
327 }
328
329 #[test]
331 fn verdict_table() {
332 use Action::*;
333 let k = key();
334 let c = cache(&k);
335 let t = TenantId("team/sub".into());
336 let rows: [(State, [bool; 4]); 5] = [
337 (State::Free, [true, true, true, true]),
338 (State::Paid, [true, true, true, true]),
339 (State::Grace, [true, true, true, true]),
340 (State::Suspended, [false, true, false, false]),
341 (State::Retention, [false, true, false, false]),
342 ];
343 for (state, expect) in rows {
344 c.push(signed(&k, fact("team/sub", state, Some(OCT_1_2026)))).unwrap();
345 for (action, allowed) in [Write, Read, AnonymousRead, Admin].into_iter().zip(expect) {
346 let v = c.allows(&t, action);
347 assert_eq!(v.is_allowed(), allowed, "{state:?} / {action:?} gave {v:?}");
348 if let Verdict::Refuse { state: s, reason_line, url } = &v {
349 assert_eq!(*s, state);
350 assert_eq!(url, "https://gunnar.rs/billing/team/sub");
351 assert!(reason_line.contains("order 10gb expired 2026-10-01"), "{reason_line}");
352 assert!(reason_line.ends_with("; renew at https://gunnar.rs/billing/team/sub"), "{reason_line}");
353 }
354 }
355 assert_eq!(c.notice(&t).is_some(), state == State::Grace, "{state:?} notice");
356 }
357 }
358
359 #[test]
360 fn refuse_twin_suspended_write_names_the_reason_and_unknown_tenant_is_free() {
361 let k = key();
362 let c = cache(&k);
363 let t = TenantId("team/sub".into());
364 c.push(signed(&k, fact("team/sub", State::Suspended, Some(OCT_1_2026)))).unwrap();
365 assert_eq!(
366 c.allows(&t, Action::Write),
367 Verdict::Refuse {
368 state: State::Suspended,
369 reason_line: "order 10gb expired 2026-10-01: writes closed; renew at https://gunnar.rs/billing/team/sub".into(),
370 url: "https://gunnar.rs/billing/team/sub".into(),
371 }
372 );
373 let op = signed(&k, fact("ops", State::Suspended, None));
374 c.push(op).unwrap();
375 assert!(matches!(c.allows(&TenantId("ops".into()), Action::Write), Verdict::Refuse { reason_line, .. } if reason_line.starts_with("order 10gb suspended by operator")));
376 assert_eq!(c.allows(&TenantId("nobody".into()), Action::Write), Verdict::Allow);
377 assert_eq!(c.state(&TenantId("nobody".into())), State::Free);
378 }
379}