1use serde::{Deserialize, Serialize};
10
11use crate::auth::session::now_epoch;
12use crate::error::AppError;
13use crate::store::KeyspaceHandle;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum ConsentKind {
19 Dm,
20 Group,
21 Channel,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum ConsentScope {
28 Receive,
29 Converse,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ConsentEffect {
36 Allow,
37 Deny,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ConsentSubject {
45 pub platform: String,
46 pub conversation_ref: String,
47 pub kind: ConsentKind,
48 pub agent: String,
49}
50
51impl ConsentSubject {
52 fn grant_key(&self) -> String {
55 format!(
56 "grant:{}\u{1f}{}\u{1f}{}",
57 self.platform, self.conversation_ref, self.agent
58 )
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConsentGrant {
65 pub subject: ConsentSubject,
66 pub effect: ConsentEffect,
67 #[serde(skip_serializing_if = "Option::is_none", default)]
69 pub scope: Option<ConsentScope>,
70 pub granted_by: String,
72 pub granted_at: u64,
73 #[serde(skip_serializing_if = "Option::is_none", default)]
75 pub expires_at: Option<u64>,
76 pub evidence: String,
78}
79
80impl ConsentGrant {
81 pub fn is_expired(&self, now: u64) -> bool {
82 self.expires_at.is_some_and(|e| now >= e)
83 }
84
85 pub fn allows(&self, now: u64) -> bool {
87 self.effect == ConsentEffect::Allow && !self.is_expired(now)
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct PendingConsent {
95 pub subject: ConsentSubject,
96 pub scope: ConsentScope,
97 pub challenge: String,
98 pub requested_by: String,
100 #[serde(skip_serializing_if = "Option::is_none", default)]
102 pub context: Option<String>,
103 pub created_at: u64,
104 pub expires_at: u64,
105}
106
107fn pending_key(challenge: &str) -> String {
108 format!("consent_pending:{challenge}")
109}
110
111#[derive(Debug, PartialEq)]
113pub enum ConsumeConsent {
114 NotFound,
115 Expired,
116 Found(Box<PendingConsent>),
117}
118
119pub async fn store_consent_grant(
123 ks: &KeyspaceHandle,
124 grant: &ConsentGrant,
125) -> Result<(), AppError> {
126 ks.insert(grant.subject.grant_key(), grant).await
127}
128
129pub async fn get_consent_grant(
131 ks: &KeyspaceHandle,
132 subject: &ConsentSubject,
133) -> Result<Option<ConsentGrant>, AppError> {
134 ks.get(subject.grant_key()).await
135}
136
137pub async fn delete_consent_grant(
139 ks: &KeyspaceHandle,
140 subject: &ConsentSubject,
141) -> Result<(), AppError> {
142 ks.remove(subject.grant_key()).await
143}
144
145pub async fn list_consent_grants(ks: &KeyspaceHandle) -> Result<Vec<ConsentGrant>, AppError> {
147 let rows = ks.prefix_iter_raw("grant:").await?;
148 let mut out = Vec::with_capacity(rows.len());
149 for (_key, value) in rows {
150 match serde_json::from_slice::<ConsentGrant>(&value) {
151 Ok(g) => out.push(g),
152 Err(e) => tracing::warn!(error = %e, "skipping undeserializable consent grant"),
153 }
154 }
155 Ok(out)
156}
157
158pub async fn store_pending_consent(
162 ks: &KeyspaceHandle,
163 pending: &PendingConsent,
164) -> Result<(), AppError> {
165 ks.insert(pending_key(&pending.challenge), pending).await
166}
167
168pub async fn consume_pending_consent(
170 ks: &KeyspaceHandle,
171 challenge: &str,
172 now: u64,
173) -> Result<ConsumeConsent, AppError> {
174 let key = pending_key(challenge);
175 let Some(pending): Option<PendingConsent> = ks.get(key.clone()).await? else {
176 return Ok(ConsumeConsent::NotFound);
177 };
178 ks.remove(key).await?; if now >= pending.expires_at {
180 return Ok(ConsumeConsent::Expired);
181 }
182 Ok(ConsumeConsent::Found(Box::new(pending)))
183}
184
185pub fn new_pending_consent(
187 subject: ConsentSubject,
188 scope: ConsentScope,
189 challenge: impl Into<String>,
190 requested_by: impl Into<String>,
191 context: Option<String>,
192 ttl_secs: u64,
193) -> PendingConsent {
194 let created_at = now_epoch();
195 PendingConsent {
196 subject,
197 scope,
198 challenge: challenge.into(),
199 requested_by: requested_by.into(),
200 context,
201 created_at,
202 expires_at: created_at + ttl_secs,
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum ConsentRoute {
212 Wake,
214 BridgeRelay,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct ApproverBinding {
223 pub platform: String,
224 pub context: String,
225 pub approver: String,
227 #[serde(skip_serializing_if = "Option::is_none", default)]
229 pub route: Option<ConsentRoute>,
230 #[serde(skip_serializing_if = "Option::is_none", default)]
232 pub route_hint: Option<String>,
233}
234
235impl ApproverBinding {
236 fn key(platform: &str, context: &str) -> String {
237 format!("approver:{platform}\u{1f}{context}")
238 }
239}
240
241pub async fn store_approver(
243 ks: &KeyspaceHandle,
244 binding: &ApproverBinding,
245) -> Result<(), AppError> {
246 ks.insert(
247 ApproverBinding::key(&binding.platform, &binding.context),
248 binding,
249 )
250 .await
251}
252
253pub async fn get_approver(
255 ks: &KeyspaceHandle,
256 platform: &str,
257 context: &str,
258) -> Result<Option<ApproverBinding>, AppError> {
259 ks.get(ApproverBinding::key(platform, context)).await
260}
261
262pub async fn delete_approver(
264 ks: &KeyspaceHandle,
265 platform: &str,
266 context: &str,
267) -> Result<(), AppError> {
268 ks.remove(ApproverBinding::key(platform, context)).await
269}
270
271pub async fn list_approvers(ks: &KeyspaceHandle) -> Result<Vec<ApproverBinding>, AppError> {
273 let rows = ks.prefix_iter_raw("approver:").await?;
274 let mut out = Vec::with_capacity(rows.len());
275 for (_key, value) in rows {
276 match serde_json::from_slice::<ApproverBinding>(&value) {
277 Ok(b) => out.push(b),
278 Err(e) => tracing::warn!(error = %e, "skipping undeserializable approver binding"),
279 }
280 }
281 Ok(out)
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::config::StoreConfig;
288 use crate::store::Store;
289
290 fn subject() -> ConsentSubject {
291 ConsentSubject {
292 platform: "signal".into(),
293 conversation_ref: "sig-1a2b3c4d".into(),
294 kind: ConsentKind::Group,
295 agent: "did:key:z6MkAgent".into(),
296 }
297 }
298
299 async fn ks() -> KeyspaceHandle {
300 let dir = tempfile::tempdir().expect("tempdir");
301 let dir = Box::leak(Box::new(dir)); let store = Store::open(&StoreConfig {
303 data_dir: dir.path().to_path_buf(),
304 })
305 .expect("open store");
306 store.keyspace("consent").expect("keyspace")
307 }
308
309 fn grant(effect: ConsentEffect) -> ConsentGrant {
310 ConsentGrant {
311 subject: subject(),
312 effect,
313 scope: matches!(effect, ConsentEffect::Allow).then_some(ConsentScope::Converse),
314 granted_by: "did:web:operator".into(),
315 granted_at: 1_000,
316 expires_at: None,
317 evidence: "bridge-attested".into(),
318 }
319 }
320
321 #[tokio::test]
322 async fn grant_store_get_delete_round_trip() {
323 let ks = ks().await;
324 assert!(get_consent_grant(&ks, &subject()).await.unwrap().is_none());
326
327 let g = grant(ConsentEffect::Allow);
328 store_consent_grant(&ks, &g).await.unwrap();
329 assert_eq!(get_consent_grant(&ks, &subject()).await.unwrap(), Some(g));
330
331 delete_consent_grant(&ks, &subject()).await.unwrap();
332 assert!(get_consent_grant(&ks, &subject()).await.unwrap().is_none());
333 }
334
335 #[tokio::test]
336 async fn list_returns_all_grants() {
337 let ks = ks().await;
338 store_consent_grant(&ks, &grant(ConsentEffect::Allow))
339 .await
340 .unwrap();
341 let mut other = grant(ConsentEffect::Deny);
342 other.subject.conversation_ref = "sig-99999999".into();
343 store_consent_grant(&ks, &other).await.unwrap();
344 assert_eq!(list_consent_grants(&ks).await.unwrap().len(), 2);
345 }
346
347 #[tokio::test]
348 async fn pending_consume_is_single_use_and_ttl_aware() {
349 let ks = ks().await;
350 let p = new_pending_consent(
351 subject(),
352 ConsentScope::Converse,
353 "chal-1",
354 "did:webvh:bridge",
355 None,
356 300,
357 );
358 store_pending_consent(&ks, &p).await.unwrap();
359
360 match consume_pending_consent(&ks, "chal-1", p.created_at + 1)
362 .await
363 .unwrap()
364 {
365 ConsumeConsent::Found(found) => assert_eq!(found.subject, subject()),
366 other => panic!("expected Found, got {other:?}"),
367 }
368 assert_eq!(
369 consume_pending_consent(&ks, "chal-1", p.created_at + 1)
370 .await
371 .unwrap(),
372 ConsumeConsent::NotFound,
373 );
374
375 let p2 = new_pending_consent(subject(), ConsentScope::Receive, "chal-2", "b", None, 10);
377 store_pending_consent(&ks, &p2).await.unwrap();
378 assert_eq!(
379 consume_pending_consent(&ks, "chal-2", p2.expires_at + 1)
380 .await
381 .unwrap(),
382 ConsumeConsent::Expired,
383 );
384 assert_eq!(
385 consume_pending_consent(&ks, "chal-2", p2.expires_at + 1)
386 .await
387 .unwrap(),
388 ConsumeConsent::NotFound,
389 );
390 }
391
392 #[test]
393 fn allow_grant_is_effective_until_expiry() {
394 let g = ConsentGrant {
395 subject: subject(),
396 effect: ConsentEffect::Allow,
397 scope: Some(ConsentScope::Converse),
398 granted_by: "did:web:operator".into(),
399 granted_at: 1_000,
400 expires_at: Some(2_000),
401 evidence: "bridge-attested".into(),
402 };
403 assert!(g.allows(1_500));
404 assert!(!g.allows(2_000)); let deny = ConsentGrant {
406 effect: ConsentEffect::Deny,
407 scope: None,
408 ..g.clone()
409 };
410 assert!(!deny.allows(1_500));
411 }
412
413 #[test]
414 fn pending_ttl_is_set_from_now() {
415 let p = new_pending_consent(
416 subject(),
417 ConsentScope::Converse,
418 "chal",
419 "did:webvh:bridge",
420 Some("ctx".into()),
421 300,
422 );
423 assert_eq!(p.expires_at, p.created_at + 300);
424 }
425
426 #[test]
427 fn grant_round_trips_through_json() {
428 let g = ConsentGrant {
429 subject: subject(),
430 effect: ConsentEffect::Allow,
431 scope: Some(ConsentScope::Receive),
432 granted_by: "did:web:operator".into(),
433 granted_at: 42,
434 expires_at: None,
435 evidence: "did-signed".into(),
436 };
437 let s = serde_json::to_string(&g).unwrap();
438 assert_eq!(serde_json::from_str::<ConsentGrant>(&s).unwrap(), g);
439 }
440
441 #[tokio::test]
442 async fn approver_store_get_list_delete_round_trip() {
443 let ks = ks().await;
444 assert!(get_approver(&ks, "signal", "ctx").await.unwrap().is_none());
445
446 let b = ApproverBinding {
447 platform: "signal".into(),
448 context: "ctx".into(),
449 approver: "did:web:operator".into(),
450 route: Some(ConsentRoute::BridgeRelay),
451 route_hint: Some("sig-0a1b".into()),
452 };
453 store_approver(&ks, &b).await.unwrap();
454 assert_eq!(
455 get_approver(&ks, "signal", "ctx").await.unwrap(),
456 Some(b.clone())
457 );
458
459 let mut other = b.clone();
461 other.platform = "slack".into();
462 store_approver(&ks, &other).await.unwrap();
463 assert_eq!(list_approvers(&ks).await.unwrap().len(), 2);
464
465 delete_approver(&ks, "signal", "ctx").await.unwrap();
466 assert!(get_approver(&ks, "signal", "ctx").await.unwrap().is_none());
467 assert_eq!(list_approvers(&ks).await.unwrap().len(), 1);
468 }
469
470 #[test]
471 fn route_serializes_kebab_case() {
472 assert_eq!(
473 serde_json::to_string(&ConsentRoute::BridgeRelay).unwrap(),
474 "\"bridge-relay\""
475 );
476 assert_eq!(
477 serde_json::to_string(&ConsentRoute::Wake).unwrap(),
478 "\"wake\""
479 );
480 }
481}