Skip to main content

vault_core/
ics_feeds.rs

1//! Calendar feed tokens — Phase 24.3.
2//!
3//! A feed is a bearer credential of its own kind: 32 random bytes, shown once,
4//! stored here only as a SHA-256 hash — the same shape `users::create_user_token`
5//! already uses for API tokens, for the same reason. `GET /ics/{token}.ics`
6//! looks a presented token up by its hash and never needs the plaintext again.
7//!
8//! `user_id = NULL` means the feed belongs to the vault owner and is built from
9//! **every** entry, unfiltered. A non-null `user_id` means the feed must be
10//! re-filtered through `filter_vault_for_user` on **every fetch**, not once at
11//! creation — a permission revoked after the feed was minted has to shrink it,
12//! or the feed outlives the access it was issued under.
13
14use rusqlite::{params, Connection, OptionalExtension};
15use serde::Serialize;
16use sha2::{Digest, Sha256};
17
18/// Creates the table if absent. Called from [`crate::init_schema`].
19pub fn init_schema(conn: &Connection) -> Result<(), String> {
20    conn.execute_batch(
21        "CREATE TABLE IF NOT EXISTS ics_feeds (
22             id              TEXT PRIMARY KEY,
23             user_id         TEXT,
24             token_hash      TEXT NOT NULL UNIQUE,
25             name            TEXT NOT NULL,
26             kinds           TEXT NOT NULL,
27             scope           TEXT NOT NULL DEFAULT '',
28             created_at      TEXT NOT NULL,
29             last_fetched_at TEXT,
30             revoked_at      TEXT
31         );
32         CREATE INDEX IF NOT EXISTS ics_feeds_token ON ics_feeds(token_hash);",
33    )
34    .map_err(|e| e.to_string())
35}
36
37#[derive(Clone, Serialize)]
38pub struct FeedRecord {
39    pub id: String,
40    pub user_id: Option<String>,
41    pub name: String,
42    /// `created` / `expires` / `rotation`, comma-joined.
43    pub kinds: String,
44    /// Comma-joined opt-in flags. Only meaningful value today: `account_names`
45    /// — off by default, because an account name is often an email address and
46    /// the design's rule is that a feed says nothing beyond names and dates
47    /// unless the person creating it asks for more.
48    pub scope: String,
49    pub created_at: String,
50    pub last_fetched_at: Option<String>,
51    pub revoked_at: Option<String>,
52}
53
54impl FeedRecord {
55    pub fn is_revoked(&self) -> bool {
56        self.revoked_at.is_some()
57    }
58
59    pub fn include_account_names(&self) -> bool {
60        self.scope.split(',').any(|s| s == "account_names")
61    }
62
63    pub fn kind_list(&self) -> Vec<String> {
64        self.kinds
65            .split(',')
66            .filter(|s| !s.is_empty())
67            .map(str::to_string)
68            .collect()
69    }
70}
71
72fn sha256_hex(input: &str) -> String {
73    hex::encode(Sha256::digest(input.as_bytes()))
74}
75
76fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<FeedRecord> {
77    Ok(FeedRecord {
78        id: row.get(0)?,
79        user_id: row.get(1)?,
80        name: row.get(2)?,
81        kinds: row.get(3)?,
82        scope: row.get(4)?,
83        created_at: row.get(5)?,
84        last_fetched_at: row.get(6)?,
85        revoked_at: row.get(7)?,
86    })
87}
88
89const COLS: &str = "id, user_id, name, kinds, scope, created_at, last_fetched_at, revoked_at";
90
91/// Mints a feed. Returns the record and the plaintext token — the **only** time
92/// the plaintext exists outside the requester's own response.
93pub fn create_feed(
94    conn: &Connection,
95    user_id: Option<&str>,
96    name: &str,
97    kinds: &[String],
98    include_account_names: bool,
99) -> Result<(FeedRecord, String), String> {
100    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
101    use rand::RngCore;
102    let mut raw = [0u8; 32];
103    rand::thread_rng().fill_bytes(&mut raw);
104    let token = URL_SAFE_NO_PAD.encode(raw);
105    let token_hash = sha256_hex(&token);
106    let id = crate::new_uuid();
107    let created_at = crate::iso_now();
108    let kinds_joined = kinds.join(",");
109    let scope = if include_account_names {
110        "account_names".to_string()
111    } else {
112        String::new()
113    };
114    conn.execute(
115        "INSERT INTO ics_feeds (id, user_id, token_hash, name, kinds, scope, created_at) \
116         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
117        params![
118            id,
119            user_id,
120            token_hash,
121            name,
122            kinds_joined,
123            scope,
124            created_at
125        ],
126    )
127    .map_err(|e| e.to_string())?;
128    Ok((
129        FeedRecord {
130            id,
131            user_id: user_id.map(str::to_string),
132            name: name.to_string(),
133            kinds: kinds_joined,
134            scope,
135            created_at,
136            last_fetched_at: None,
137            revoked_at: None,
138        },
139        token,
140    ))
141}
142
143/// Feeds visible to the caller: every feed for the owner, only the caller's own
144/// otherwise.
145pub fn list_feeds(
146    conn: &Connection,
147    is_owner: bool,
148    user_id: &str,
149) -> Result<Vec<FeedRecord>, String> {
150    let sql = format!(
151        "SELECT {COLS} FROM ics_feeds {} ORDER BY created_at DESC",
152        if is_owner { "" } else { "WHERE user_id = ?1" }
153    );
154    let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
155    let rows = if is_owner {
156        stmt.query_map([], row_to_record)
157    } else {
158        stmt.query_map(params![user_id], row_to_record)
159    }
160    .map_err(|e| e.to_string())?;
161    rows.collect::<Result<Vec<_>, _>>()
162        .map_err(|e| e.to_string())
163}
164
165/// Revokes a feed. An owner may revoke any feed; a sub-user only their own —
166/// enforced here rather than trusted to the caller, since this is the one
167/// place that decides whether a URL someone thought they killed still works.
168pub fn revoke_feed(
169    conn: &Connection,
170    feed_id: &str,
171    actor_user_id: &str,
172    is_owner: bool,
173) -> Result<bool, String> {
174    let n = if is_owner {
175        conn.execute(
176            "UPDATE ics_feeds SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL",
177            params![crate::iso_now(), feed_id],
178        )
179    } else {
180        conn.execute(
181            "UPDATE ics_feeds SET revoked_at = ?1 \
182             WHERE id = ?2 AND user_id = ?3 AND revoked_at IS NULL",
183            params![crate::iso_now(), feed_id, actor_user_id],
184        )
185    }
186    .map_err(|e| e.to_string())?;
187    Ok(n > 0)
188}
189
190/// Looks a presented token up by its hash. Revoked feeds are excluded here
191/// rather than filtered by the caller, so a revoked token can never be reached
192/// by forgetting one check site.
193pub fn find_active_feed_by_token(
194    conn: &Connection,
195    raw_token: &str,
196) -> Result<Option<FeedRecord>, String> {
197    let hash = sha256_hex(raw_token);
198    conn.query_row(
199        &format!("SELECT {COLS} FROM ics_feeds WHERE token_hash = ?1 AND revoked_at IS NULL"),
200        params![hash],
201        row_to_record,
202    )
203    .optional()
204    .map_err(|e| e.to_string())
205}
206
207/// Stamps the last-fetch time. Deliberately not an audited write — a calendar
208/// client polling hourly would grow the hash-chained audit log without bound,
209/// the same reason vault reads stopped being audited.
210pub fn touch_feed(conn: &Connection, feed_id: &str) -> Result<(), String> {
211    conn.execute(
212        "UPDATE ics_feeds SET last_fetched_at = ?1 WHERE id = ?2",
213        params![crate::iso_now(), feed_id],
214    )
215    .map(|_| ())
216    .map_err(|e| e.to_string())
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn mem() -> Connection {
224        let conn = Connection::open_in_memory().unwrap();
225        init_schema(&conn).unwrap();
226        conn
227    }
228
229    #[test]
230    fn a_revoked_feed_is_not_found_by_its_token() {
231        let conn = mem();
232        let (feed, token) =
233            create_feed(&conn, None, "Everything", &["expires".to_string()], false).unwrap();
234        assert!(find_active_feed_by_token(&conn, &token).unwrap().is_some());
235        assert!(revoke_feed(&conn, &feed.id, "owner", true).unwrap());
236        assert!(find_active_feed_by_token(&conn, &token).unwrap().is_none());
237    }
238
239    #[test]
240    fn a_sub_user_cannot_revoke_someone_elses_feed() {
241        let conn = mem();
242        let (feed, _) = create_feed(
243            &conn,
244            Some("alice"),
245            "Alice's feed",
246            &["expires".to_string()],
247            false,
248        )
249        .unwrap();
250        assert!(!revoke_feed(&conn, &feed.id, "bob", false).unwrap());
251        assert!(revoke_feed(&conn, &feed.id, "alice", false).unwrap());
252    }
253
254    #[test]
255    fn account_names_are_opt_in() {
256        let conn = mem();
257        let (feed, _) = create_feed(&conn, None, "X", &["expires".to_string()], false).unwrap();
258        assert!(!feed.include_account_names());
259        let (feed2, _) = create_feed(&conn, None, "Y", &["expires".to_string()], true).unwrap();
260        assert!(feed2.include_account_names());
261    }
262
263    #[test]
264    fn an_unknown_token_finds_nothing() {
265        let conn = mem();
266        assert!(find_active_feed_by_token(&conn, "not-a-real-token")
267            .unwrap()
268            .is_none());
269    }
270}