Skip to main content

ringdrop/core/
grants.rs

1//! Privilege-based grant store: which peers may invoke which catalog operations.
2//!
3//! Grants are stored in a dedicated redb database (`grants.redb`) separate
4//! from the ring registry. The data model is a single table keyed by a
5//! composite of privilege name and peer identity:
6//!
7//! ```text
8//! GRANTS   privilege\0peer_id_bytes[32] → ()
9//! ```
10//!
11//! The NUL separator between the privilege string and the 32-byte peer key
12//! is unambiguous because privilege names are validated to contain no NUL.
13//!
14//! # Current privileges
15//!
16//! | Privilege | Value | Grants |
17//! |-----------|-------|--------|
18//! | [`Privilege::BlobList`] | `"blob-list"` | Query the peer's catalog via `/ringdrop/catalog/0` |
19
20use std::fmt;
21use std::path::Path;
22use std::sync::Arc;
23
24use anyhow::{Context, Result};
25use iroh::EndpointId;
26use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
27
28/// Table mapping `privilege\0peer_id_bytes[32]` to `()`.
29const GRANTS: TableDefinition<'_, &[u8], ()> = TableDefinition::new("grants");
30
31/// A named capability that can be granted to a remote peer.
32///
33/// Validated at write time so unknown privilege strings are rejected before
34/// they reach the database.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum Privilege {
38    /// Allows the peer to query the local catalog via `/ringdrop/catalog/0`
39    /// and receive the list of blobs they can download.
40    BlobList,
41}
42
43impl Privilege {
44    /// Returns the canonical wire/storage string for this privilege.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Privilege::BlobList => "blob-list",
48        }
49    }
50}
51
52impl fmt::Display for Privilege {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58impl TryFrom<&str> for Privilege {
59    type Error = anyhow::Error;
60
61    /// Parse a privilege from its canonical string representation.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the string does not match any known privilege.
66    fn try_from(s: &str) -> Result<Self> {
67        match s {
68            "blob-list" => Ok(Privilege::BlobList),
69            _ => anyhow::bail!("unknown privilege: {s:?}"),
70        }
71    }
72}
73
74/// Persistent store for peer privilege grants, backed by a redb database.
75///
76/// Cheaply cloneable via internal [`Arc`].
77#[derive(Clone)]
78pub struct GrantStore {
79    db: Arc<Database>,
80}
81
82impl GrantStore {
83    /// Open (or create) the grant store at `path`.
84    ///
85    /// Creates the `GRANTS` table on first use.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the database file cannot be opened or the initial
90    /// table setup fails.
91    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
92        let db = Database::create(path).context("opening grants database")?;
93        let write = db
94            .begin_write()
95            .context("starting grants init transaction")?;
96        write.open_table(GRANTS).context("creating grants table")?;
97        write.commit().context("committing grants init")?;
98        Ok(Self { db: Arc::new(db) })
99    }
100
101    /// Grant `privilege` to `peer`.
102    ///
103    /// Idempotent: granting an already-granted privilege is a no-op.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the database write fails.
108    pub fn grant(&self, privilege: Privilege, peer: EndpointId) -> Result<()> {
109        let key = grant_key(privilege, &peer);
110        let write = self.db.begin_write().context("beginning grant write")?;
111        {
112            let mut table = write.open_table(GRANTS).context("opening grants table")?;
113            table
114                .insert(key.as_slice(), ())
115                .context("inserting grant")?;
116        }
117        write.commit().context("committing grant")?;
118        Ok(())
119    }
120
121    /// Revoke `privilege` from `peer`.
122    ///
123    /// Idempotent: revoking a non-existent grant is a no-op.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the database write fails.
128    pub fn revoke(&self, privilege: Privilege, peer: EndpointId) -> Result<()> {
129        let key = grant_key(privilege, &peer);
130        let write = self.db.begin_write().context("beginning revoke write")?;
131        {
132            let mut table = write.open_table(GRANTS).context("opening grants table")?;
133            table.remove(key.as_slice()).context("removing grant")?;
134        }
135        write.commit().context("committing revoke")?;
136        Ok(())
137    }
138
139    /// Returns `true` if `peer` currently holds `privilege`.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if the database read fails.
144    pub fn has_grant(&self, privilege: Privilege, peer: &EndpointId) -> Result<bool> {
145        let key = grant_key(privilege, peer);
146        let read = self.db.begin_read().context("beginning grant read")?;
147        let table = read.open_table(GRANTS).context("opening grants table")?;
148        Ok(table
149            .get(key.as_slice())
150            .context("querying grant")?
151            .is_some())
152    }
153
154    /// Returns all current grants as `(privilege, peer_id)` pairs.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the database read or key decoding fails.
159    pub fn list(&self) -> Result<Vec<(Privilege, EndpointId)>> {
160        let read = self.db.begin_read().context("beginning grants list read")?;
161        let table = read.open_table(GRANTS).context("opening grants table")?;
162        let mut result = Vec::new();
163        for item in table.iter().context("iterating grants")? {
164            let (k, _) = item.context("reading grant entry")?;
165            let (privilege, peer) = decode_grant_key(k.value())?;
166            result.push((privilege, peer));
167        }
168        Ok(result)
169    }
170}
171
172/// Composite key: `privilege_bytes + NUL + peer_id_bytes[32]`.
173fn grant_key(privilege: Privilege, peer: &EndpointId) -> Vec<u8> {
174    let priv_bytes = privilege.as_str().as_bytes();
175    let mut key = Vec::with_capacity(priv_bytes.len() + 1 + 32);
176    key.extend_from_slice(priv_bytes);
177    key.push(b'\0');
178    key.extend_from_slice(peer.as_bytes());
179    key
180}
181
182/// Decode a raw key back into `(Privilege, EndpointId)`.
183fn decode_grant_key(key: &[u8]) -> Result<(Privilege, EndpointId)> {
184    let sep = key
185        .iter()
186        .position(|&b| b == b'\0')
187        .context("grant key missing NUL separator")?;
188    let priv_str = std::str::from_utf8(&key[..sep]).context("grant key privilege not UTF-8")?;
189    let privilege = Privilege::try_from(priv_str)?;
190    let peer_bytes: [u8; 32] = key[sep + 1..]
191        .try_into()
192        .context("grant key peer id not 32 bytes")?;
193    let peer = EndpointId::from_bytes(&peer_bytes).context("grant key invalid peer id")?;
194    Ok((privilege, peer))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use tempfile::tempdir;
201
202    fn open_store() -> (GrantStore, tempfile::TempDir) {
203        let dir = tempdir().unwrap();
204        let store = GrantStore::open(dir.path().join("grants.redb")).unwrap();
205        (store, dir)
206    }
207
208    fn peer() -> EndpointId {
209        iroh::SecretKey::generate().public()
210    }
211
212    #[test]
213    fn grant_and_has_grant_returns_true() {
214        let (store, _dir) = open_store();
215        let peer = peer();
216        store.grant(Privilege::BlobList, peer).unwrap();
217        assert!(store.has_grant(Privilege::BlobList, &peer).unwrap());
218    }
219
220    #[test]
221    fn has_grant_returns_false_for_unknown_peer() {
222        let (store, _dir) = open_store();
223        assert!(!store.has_grant(Privilege::BlobList, &peer()).unwrap());
224    }
225
226    #[test]
227    fn revoke_removes_grant() {
228        let (store, _dir) = open_store();
229        let peer = peer();
230        store.grant(Privilege::BlobList, peer).unwrap();
231        store.revoke(Privilege::BlobList, peer).unwrap();
232        assert!(!store.has_grant(Privilege::BlobList, &peer).unwrap());
233    }
234
235    #[test]
236    fn revoke_non_existent_grant_is_idempotent() {
237        let (store, _dir) = open_store();
238        store.revoke(Privilege::BlobList, peer()).unwrap();
239    }
240
241    #[test]
242    fn grant_is_idempotent() {
243        let (store, _dir) = open_store();
244        let peer = peer();
245        store.grant(Privilege::BlobList, peer).unwrap();
246        store.grant(Privilege::BlobList, peer).unwrap();
247        assert!(store.has_grant(Privilege::BlobList, &peer).unwrap());
248    }
249
250    #[test]
251    fn list_returns_all_current_grants() {
252        let (store, _dir) = open_store();
253        let p1 = peer();
254        let p2 = peer();
255        store.grant(Privilege::BlobList, p1).unwrap();
256        store.grant(Privilege::BlobList, p2).unwrap();
257        let grants = store.list().unwrap();
258        assert_eq!(grants.len(), 2);
259        assert!(grants
260            .iter()
261            .any(|(priv_, id)| *priv_ == Privilege::BlobList && *id == p1));
262        assert!(grants
263            .iter()
264            .any(|(priv_, id)| *priv_ == Privilege::BlobList && *id == p2));
265    }
266
267    #[test]
268    fn list_on_empty_store_returns_empty_vec() {
269        let (store, _dir) = open_store();
270        assert!(store.list().unwrap().is_empty());
271    }
272
273    #[test]
274    fn privilege_display_matches_wire_string() {
275        assert_eq!(Privilege::BlobList.to_string(), "blob-list");
276    }
277
278    #[test]
279    fn privilege_round_trips_through_string() {
280        let p = Privilege::BlobList;
281        assert_eq!(Privilege::try_from(p.as_str()).unwrap(), p);
282    }
283
284    #[test]
285    fn unknown_privilege_string_errors() {
286        assert!(Privilege::try_from("admin").is_err());
287    }
288
289    #[test]
290    fn grants_persist_across_close_and_reopen() {
291        let dir = tempdir().unwrap();
292        let peer = peer();
293        {
294            let store = GrantStore::open(dir.path().join("grants.redb")).unwrap();
295            store.grant(Privilege::BlobList, peer).unwrap();
296        }
297        let store = GrantStore::open(dir.path().join("grants.redb")).unwrap();
298        assert!(store.has_grant(Privilege::BlobList, &peer).unwrap());
299    }
300}