Skip to main content

vti_common/
consent.rs

1//! Consent store — records + KV helpers for VTA-gated inbound messaging.
2//!
3//! Generic across platforms / agents / interaction kinds (see the `consent/*`
4//! Trust Task family). A messaging bridge asks the VTA to gate a conversation
5//! (**default-deny**); an approver's decision is recorded as a [`ConsentGrant`]
6//! the bridge then enforces. The pending-request store mirrors the step-up
7//! pattern in [`crate::auth::step_up`] (challenge-keyed, single-use, TTL'd).
8
9use serde::{Deserialize, Serialize};
10
11use crate::auth::session::now_epoch;
12use crate::error::AppError;
13use crate::store::KeyspaceHandle;
14
15/// Interaction kind — a 1:1 DM, a multi-party group, or a broadcast channel.
16#[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/// What the agent may do on a conversation: read inbound, or read and reply.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum ConsentScope {
28    Receive,
29    Converse,
30}
31
32/// Allow or deny. The ABSENCE of a grant is treated as deny (default-deny).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ConsentEffect {
36    Allow,
37    Deny,
38}
39
40/// Platform-agnostic identifier of WHAT consent is about: one conversation, for
41/// one agent. `conversation_ref` is the bridge's OPAQUE handle — never a raw
42/// platform address.
43#[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    /// Storage key for a grant over this subject. Unit-separator (`\x1f`) joined
53    /// so the colons in `agent` (a DID) can't collide with the field delimiter.
54    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/// A recorded consent decision over a [`ConsentSubject`].
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConsentGrant {
65    pub subject: ConsentSubject,
66    pub effect: ConsentEffect,
67    /// Granted scope; present when `effect == Allow`.
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    pub scope: Option<ConsentScope>,
70    /// VID of the approver who made the decision.
71    pub granted_by: String,
72    pub granted_at: u64,
73    /// Optional TTL; after this the grant lapses and the subject re-consents.
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    pub expires_at: Option<u64>,
76    /// How the decision was authorized, e.g. `"did-signed"` | `"bridge-attested"`.
77    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    /// Effective allow: an `allow` grant that has not expired.
86    pub fn allows(&self, now: u64) -> bool {
87        self.effect == ConsentEffect::Allow && !self.is_expired(now)
88    }
89}
90
91/// A pending consent request awaiting an approver decision. Keyed by challenge,
92/// single-use, TTL'd — mirrors [`crate::auth::step_up::PendingStepUp`].
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct PendingConsent {
95    pub subject: ConsentSubject,
96    pub scope: ConsentScope,
97    pub challenge: String,
98    /// The bridge DID that raised the request.
99    pub requested_by: String,
100    /// The VTA context the subject was scoped to (drives approver resolution).
101    #[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/// Outcome of consuming a pending consent by challenge.
112#[derive(Debug, PartialEq)]
113pub enum ConsumeConsent {
114    NotFound,
115    Expired,
116    Found(Box<PendingConsent>),
117}
118
119// ── Grants ───────────────────────────────────────────────────────────────────
120
121/// Store (upsert) a consent grant keyed by its subject.
122pub 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
129/// Read the grant for a subject, if any.
130pub 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
137/// Delete the grant for a subject (revert to default-deny).
138pub async fn delete_consent_grant(
139    ks: &KeyspaceHandle,
140    subject: &ConsentSubject,
141) -> Result<(), AppError> {
142    ks.remove(subject.grant_key()).await
143}
144
145/// All grants. Callers filter by agent / platform / subject in memory.
146pub 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
158// ── Pending ──────────────────────────────────────────────────────────────────
159
160/// Store a pending consent keyed by its challenge.
161pub 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
168/// Locate and **consume** the pending consent matching `challenge` (single-use).
169pub 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?; // single-use, live or expired
179    if now >= pending.expires_at {
180        return Ok(ConsumeConsent::Expired);
181    }
182    Ok(ConsumeConsent::Found(Box::new(pending)))
183}
184
185/// Build a pending consent expiring `ttl_secs` from now.
186pub 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// ── Approver registry (Track A) ──────────────────────────────────────────────
207
208/// How a consent prompt reaches the approver.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum ConsentRoute {
212    /// Push to the approver's device for a DID-signed decision.
213    Wake,
214    /// Render through an enrolled bridge (e.g. a card in the operator's app) for
215    /// a bridge-attested decision.
216    BridgeRelay,
217}
218
219/// Who approves inbound-messaging consent for a platform within a context, and
220/// how the prompt reaches them. Keyed by `(platform, context)`.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct ApproverBinding {
223    pub platform: String,
224    pub context: String,
225    /// VID of the operator authorized to decide consent.
226    pub approver: String,
227    /// How to deliver the prompt; treated as `bridge-relay` when absent.
228    #[serde(skip_serializing_if = "Option::is_none", default)]
229    pub route: Option<ConsentRoute>,
230    /// Optional routing detail — e.g. the operator's opaque conversationRef.
231    #[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
241/// Store (upsert) an approver binding keyed by `(platform, context)`.
242pub 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
253/// Resolve the approver bound for `(platform, context)`, if any.
254pub 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
262/// Delete the binding for `(platform, context)`.
263pub 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
271/// All approver bindings. Callers filter by platform / context in memory.
272pub 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)); // outlive the test
302        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        // Default-deny: nothing stored yet.
325        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        // Live match → Found, and consumed (single-use).
361        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        // Expired match → Expired (and still removed).
376        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)); // expired
405        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        // A second platform in the same context is independent.
460        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}