Skip to main content

pacenotes_server/auth/
access_decision.rs

1/// A credential and its grants, as stored.
2pub struct TokenRow {
3    pub token_hash: String,
4    pub label: Option<String>,
5    /// Unix seconds.
6    pub expires_at: Option<i64>,
7    pub revoked: bool,
8    /// The directories this credential may list and download.
9    pub grants: Vec<String>,
10}
11
12impl TokenRow {
13    /// The one definition of "usable" that both `decide` and the `/packages` filter rely on.
14    pub fn is_live(&self, now: i64) -> bool {
15        !self.revoked && self.expires_at.is_none_or(|deadline| deadline > now)
16    }
17}
18
19pub enum Decision {
20    Allow,
21    /// 401: no live credential where one is needed, whether or not the package
22    /// exists, so an anonymous caller learns nothing about a directory.
23    Unauthorized,
24    /// 404, the answer for a package that does not exist, so a key holder cannot
25    /// learn what an ungranted directory holds.
26    Hidden,
27}
28
29/// `public` is always allowed; any other directory needs a live credential
30/// granted it, and a live credential without that grant sees the directory as absent.
31pub fn decide(token: Option<&TokenRow>, directory: &str, now: i64) -> Decision {
32    if directory == pacenotes_shared::PUBLIC_DIR {
33        return Decision::Allow;
34    }
35    match token {
36        Some(token) if !token.is_live(now) => Decision::Unauthorized,
37        Some(token) if token.grants.iter().any(|grant| grant == directory) => Decision::Allow,
38        Some(_) => Decision::Hidden,
39        None => Decision::Unauthorized,
40    }
41}
42
43/// Current unix seconds.
44pub fn now() -> i64 {
45    std::time::SystemTime::now()
46        .duration_since(std::time::UNIX_EPOCH)
47        .map(|since_epoch| since_epoch.as_secs() as i64)
48        .unwrap_or(0)
49}
50
51#[cfg(test)]
52mod tests {
53    fn token(revoked: bool, expires_at: Option<i64>, grants: &[&str]) -> Option<super::TokenRow> {
54        Some(super::TokenRow {
55            token_hash: "x".into(),
56            label: None,
57            expires_at,
58            revoked,
59            grants: grants.iter().map(|grant| grant.to_string()).collect(),
60        })
61    }
62
63    fn is_allow(decision: &super::Decision) -> bool {
64        matches!(decision, super::Decision::Allow)
65    }
66
67    fn is_unauthorized(decision: &super::Decision) -> bool {
68        matches!(decision, super::Decision::Unauthorized)
69    }
70
71    fn is_hidden(decision: &super::Decision) -> bool {
72        matches!(decision, super::Decision::Hidden)
73    }
74
75    type Case = (
76        &'static str,
77        Option<super::TokenRow>,
78        &'static str,
79        fn(&super::Decision) -> bool,
80    );
81
82    #[test]
83    fn decision_table() {
84        let now = 1_000;
85        let public = pacenotes_shared::PUBLIC_DIR;
86        let premium = "premium-voices";
87        let cases: Vec<Case> = vec![
88            ("public, anonymous", None, public, is_allow),
89            (
90                "public, revoked",
91                token(true, Some(1), &[]),
92                public,
93                is_allow,
94            ),
95            ("anonymous", None, premium, is_unauthorized),
96            ("no grants", token(false, None, &[]), premium, is_hidden),
97            (
98                "other grant",
99                token(false, None, &["beta"]),
100                premium,
101                is_hidden,
102            ),
103            (
104                "matching grant",
105                token(false, None, &[premium]),
106                premium,
107                is_allow,
108            ),
109            (
110                "one of several",
111                token(false, None, &["beta", premium]),
112                premium,
113                is_allow,
114            ),
115            (
116                "revoked",
117                token(true, None, &[premium]),
118                premium,
119                is_unauthorized,
120            ),
121            (
122                "revoked, other grant",
123                token(true, None, &["beta"]),
124                premium,
125                is_unauthorized,
126            ),
127            (
128                "expired",
129                token(false, Some(999), &[premium]),
130                premium,
131                is_unauthorized,
132            ),
133            (
134                "expires now",
135                token(false, Some(1_000), &[premium]),
136                premium,
137                is_unauthorized,
138            ),
139            (
140                "not yet expired",
141                token(false, Some(1_001), &[premium]),
142                premium,
143                is_allow,
144            ),
145        ];
146        for (case, token, directory, expected) in &cases {
147            let decision = super::decide(token.as_ref(), directory, now);
148            assert!(expected(&decision), "{case}");
149        }
150    }
151}