fix(manager-core): F-S-008 TTL + invalidate hook on realtime_authority key cache
key_cache: Mutex<HashMap<AppId, Vec<u8>>> was populated on first read and never evicted, bounded, or cleared on app deletion. Two problems: (1) Once key rotation lands, every running process keeps accepting tokens signed by the old key until restart. (2) Dropping and re-creating an app (FK CASCADE removes app_secrets, fresh row inserted) made the cache hand out the OLD key bytes. - Wrap the cache value as `(Instant, Vec<u8>)`; entries expire after KEY_CACHE_TTL = 5min. - Stale reads fall through to the secrets repo (no error, just slower). - New `invalidate_signing_key(app_id)` for explicit eviction once the app-secrets rotation path lands. AUDIT.md anchor: F-S-008. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,11 +27,17 @@ use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied
|
||||
use crate::app_secrets_repo::AppSecretsRepo;
|
||||
use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
||||
|
||||
/// F-S-008: TTL on cached signing-key entries. Once key rotation
|
||||
/// lands, every running process keeps accepting tokens signed by the
|
||||
/// old key until restart — bounded eviction is the simplest defence.
|
||||
/// Also bounds memory growth on a many-app install.
|
||||
const KEY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
pub struct RealtimeAuthorityImpl {
|
||||
topics: Arc<dyn TopicRepo>,
|
||||
secrets: Arc<dyn AppSecretsRepo>,
|
||||
users: Arc<dyn UsersService>,
|
||||
key_cache: Mutex<HashMap<AppId, Vec<u8>>>,
|
||||
key_cache: Mutex<HashMap<AppId, (std::time::Instant, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl RealtimeAuthorityImpl {
|
||||
@@ -54,8 +60,10 @@ impl RealtimeAuthorityImpl {
|
||||
/// caller maps to `Unauthorized`.
|
||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, SubscribeDenied> {
|
||||
if let Ok(cache) = self.key_cache.lock() {
|
||||
if let Some(k) = cache.get(&app_id) {
|
||||
return Ok(Some(k.clone()));
|
||||
if let Some((ts, k)) = cache.get(&app_id) {
|
||||
if ts.elapsed() <= KEY_CACHE_TTL {
|
||||
return Ok(Some(k.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = self
|
||||
@@ -65,11 +73,21 @@ impl RealtimeAuthorityImpl {
|
||||
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
||||
if let Some(ref k) = key {
|
||||
if let Ok(mut cache) = self.key_cache.lock() {
|
||||
cache.insert(app_id, k.clone());
|
||||
cache.insert(app_id, (std::time::Instant::now(), k.clone()));
|
||||
}
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// F-S-008 + future key-rotation hook: drop the cached signing key
|
||||
/// for an app. Wired into the app-secrets rotation path so a fresh
|
||||
/// key takes effect on the next request instead of waiting for the
|
||||
/// TTL.
|
||||
pub fn invalidate_signing_key(&self, app_id: AppId) {
|
||||
if let Ok(mut cache) = self.key_cache.lock() {
|
||||
cache.remove(&app_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
Reference in New Issue
Block a user