Skip to main content

hackamore_control/
tokens.rs

1//! Short-lived launch tokens. The orchestrator mints a token bound to a submitted
2//! [`Policy`] with a TTL; the consumer presents it to the proxy, which resolves it back
3//! to that policy. The token is an opaque capability honored only by hackamore — it is
4//! useless against the real upstream — and is revocable at any time. There is no agent
5//! identity: the token *is* the policy binding.
6//!
7//! Time is passed in explicitly (`now_ms`) so minting, expiry, and resolution are all
8//! deterministically testable; the binary supplies the wall clock via [`crate::now_ms`].
9
10use hackamore_models::control::MintResponse;
11use hackamore_models::policy::Policy;
12use parking_lot::RwLock;
13use std::collections::HashMap;
14use uuid::Uuid;
15
16struct Entry {
17    policy: Policy,
18    expires_at_ms: u64,
19    /// For a SigV4 dummy credential, the dummy secret access key (used to verify the
20    /// consumer's inbound signature). `None` for a bearer token.
21    secret: Option<String>,
22}
23
24/// A minted dummy AWS SigV4 credential, bound to a policy. The consumer's tooling signs
25/// with it; hackamore verifies that signature (with [`Tokens::resolve_sigv4`]) and re-signs
26/// the outbound request with the real account credential. Useless against real AWS.
27pub struct SigV4Mint {
28    pub access_key_id: String,
29    pub secret_access_key: String,
30    pub expires_at_ms: u64,
31}
32
33/// The in-memory token table. Keys are either an opaque bearer token or a dummy AWS access
34/// key id; both map to `(policy, expiry, optional secret)`.
35#[derive(Default)]
36pub struct Tokens {
37    entries: RwLock<HashMap<String, Entry>>,
38}
39
40impl Tokens {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Mint a fresh token bound to `policy`, valid for `ttl_seconds` from `now_ms`.
46    pub fn mint(&self, policy: Policy, ttl_seconds: u64, now_ms: u64) -> MintResponse {
47        // Two v4 UUIDs concatenated: ~244 bits of entropy, unguessable.
48        let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
49        let expires_at_ms = now_ms.saturating_add(ttl_seconds.saturating_mul(1000));
50        self.entries.write().insert(
51            token.clone(),
52            Entry {
53                policy,
54                expires_at_ms,
55                secret: None,
56            },
57        );
58        MintResponse {
59            token,
60            expires_at_ms,
61        }
62    }
63
64    /// Mint a dummy AWS SigV4 credential bound to `policy`. The access key id is the
65    /// lookup key; the secret is stored to verify inbound signatures.
66    pub fn mint_sigv4(&self, policy: Policy, ttl_seconds: u64, now_ms: u64) -> SigV4Mint {
67        let access_key_id = format!(
68            "AKIAHACKAMORE{}",
69            &Uuid::new_v4().simple().to_string()[..10]
70        )
71        .to_ascii_uppercase();
72        let secret_access_key = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
73        let expires_at_ms = now_ms.saturating_add(ttl_seconds.saturating_mul(1000));
74        self.entries.write().insert(
75            access_key_id.clone(),
76            Entry {
77                policy,
78                expires_at_ms,
79                secret: Some(secret_access_key.clone()),
80            },
81        );
82        SigV4Mint {
83            access_key_id,
84            secret_access_key,
85            expires_at_ms,
86        }
87    }
88
89    /// Resolve a dummy AWS access key id to its bound policy and dummy secret, or `None`
90    /// if unknown, expired, or not a SigV4 credential.
91    pub fn resolve_sigv4(&self, access_key_id: &str, now_ms: u64) -> Option<(Policy, String)> {
92        let entries = self.entries.read();
93        let entry = entries.get(access_key_id)?;
94        if entry.expires_at_ms <= now_ms {
95            return None;
96        }
97        let secret = entry.secret.clone()?;
98        Some((entry.policy.clone(), secret))
99    }
100
101    /// Resolve a token to its bound policy, or `None` if unknown or expired at `now_ms`.
102    pub fn resolve(&self, token: &str, now_ms: u64) -> Option<Policy> {
103        self.resolve_full(token, now_ms).map(|(policy, _)| policy)
104    }
105
106    /// Resolve a token to its bound policy and absolute expiry, or `None` if unknown or
107    /// expired at `now_ms`. Used by the provision projection.
108    pub fn resolve_full(&self, token: &str, now_ms: u64) -> Option<(Policy, u64)> {
109        let entries = self.entries.read();
110        let entry = entries.get(token)?;
111        if entry.expires_at_ms <= now_ms {
112            return None;
113        }
114        Some((entry.policy.clone(), entry.expires_at_ms))
115    }
116
117    /// Revoke a token immediately. Returns whether a token was removed.
118    pub fn revoke(&self, token: &str) -> bool {
119        self.entries.write().remove(token).is_some()
120    }
121
122    /// Evict every entry expired at `now_ms`, returning how many were removed. `resolve`
123    /// already refuses expired entries, but without this the table only grows — every
124    /// SigV4 `/provision` mints a dummy credential that would otherwise never be reclaimed.
125    /// A background sweeper calls this periodically.
126    pub fn sweep(&self, now_ms: u64) -> usize {
127        let mut entries = self.entries.write();
128        let before = entries.len();
129        entries.retain(|_, e| e.expires_at_ms > now_ms);
130        before - entries.len()
131    }
132
133    /// The number of live (un-swept) entries. For metrics/tests.
134    pub fn len(&self) -> usize {
135        self.entries.read().len()
136    }
137
138    /// Whether the token table is empty.
139    pub fn is_empty(&self) -> bool {
140        self.entries.read().is_empty()
141    }
142}
143
144#[cfg(test)]
145#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
146mod tests {
147    use super::*;
148
149    fn empty_policy() -> Policy {
150        Policy { rules: vec![] }
151    }
152
153    #[test]
154    fn mint_then_resolve_within_ttl() {
155        let tokens = Tokens::new();
156        let minted = tokens.mint(empty_policy(), 60, 1_000);
157        assert!(tokens.resolve(&minted.token, 1_000).is_some());
158        // Just before expiry.
159        assert!(tokens.resolve(&minted.token, 60_999).is_some());
160    }
161
162    #[test]
163    fn token_expires() {
164        let tokens = Tokens::new();
165        let minted = tokens.mint(empty_policy(), 60, 1_000);
166        // At/after expiry (1000 + 60_000).
167        assert!(tokens.resolve(&minted.token, 61_000).is_none());
168    }
169
170    #[test]
171    fn unknown_token_resolves_none() {
172        let tokens = Tokens::new();
173        assert!(tokens.resolve("bogus", 1).is_none());
174    }
175
176    #[test]
177    fn sigv4_mint_resolves_by_access_key_id() {
178        let tokens = Tokens::new();
179        let m = tokens.mint_sigv4(empty_policy(), 60, 1_000);
180        assert!(m.access_key_id.starts_with("AKIAHACKAMORE"));
181        let (_policy, secret) = tokens.resolve_sigv4(&m.access_key_id, 1_000).unwrap();
182        assert_eq!(secret, m.secret_access_key);
183        // Expired and unknown both miss.
184        assert!(tokens.resolve_sigv4(&m.access_key_id, 61_000).is_none());
185        assert!(tokens.resolve_sigv4("AKIAUNKNOWN", 1_000).is_none());
186        // A bearer token is not a SigV4 credential.
187        let bearer = tokens.mint(empty_policy(), 60, 1_000);
188        assert!(tokens.resolve_sigv4(&bearer.token, 1_000).is_none());
189    }
190
191    #[test]
192    fn revoke_invalidates() {
193        let tokens = Tokens::new();
194        let minted = tokens.mint(empty_policy(), 60, 0);
195        assert!(tokens.revoke(&minted.token));
196        assert!(tokens.resolve(&minted.token, 1).is_none());
197        assert!(!tokens.revoke(&minted.token));
198    }
199
200    #[test]
201    fn sweep_evicts_only_expired_entries() {
202        let tokens = Tokens::new();
203        // One short-lived (60s) and one long-lived (3600s) token, minted at t=1000.
204        let short = tokens.mint(empty_policy(), 60, 1_000);
205        let long = tokens.mint(empty_policy(), 3600, 1_000);
206        let dummy = tokens.mint_sigv4(empty_policy(), 60, 1_000);
207        assert_eq!(tokens.len(), 3);
208
209        // At t=61_000 the short token and dummy cred are expired; sweep reclaims exactly
210        // those two and leaves the long-lived token resolvable.
211        assert_eq!(tokens.sweep(61_000), 2);
212        assert_eq!(tokens.len(), 1);
213        assert!(tokens.resolve(&short.token, 61_000).is_none());
214        assert!(tokens.resolve_sigv4(&dummy.access_key_id, 61_000).is_none());
215        assert!(tokens.resolve(&long.token, 61_000).is_some());
216
217        // A second sweep at the same time is a no-op.
218        assert_eq!(tokens.sweep(61_000), 0);
219    }
220}