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::app_secrets_repo::AppSecretsRepo;
|
||||||
use crate::topic_repo::{TopicAuthMode, TopicRepo};
|
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 {
|
pub struct RealtimeAuthorityImpl {
|
||||||
topics: Arc<dyn TopicRepo>,
|
topics: Arc<dyn TopicRepo>,
|
||||||
secrets: Arc<dyn AppSecretsRepo>,
|
secrets: Arc<dyn AppSecretsRepo>,
|
||||||
users: Arc<dyn UsersService>,
|
users: Arc<dyn UsersService>,
|
||||||
key_cache: Mutex<HashMap<AppId, Vec<u8>>>,
|
key_cache: Mutex<HashMap<AppId, (std::time::Instant, Vec<u8>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RealtimeAuthorityImpl {
|
impl RealtimeAuthorityImpl {
|
||||||
@@ -54,8 +60,10 @@ impl RealtimeAuthorityImpl {
|
|||||||
/// caller maps to `Unauthorized`.
|
/// caller maps to `Unauthorized`.
|
||||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, SubscribeDenied> {
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, SubscribeDenied> {
|
||||||
if let Ok(cache) = self.key_cache.lock() {
|
if let Ok(cache) = self.key_cache.lock() {
|
||||||
if let Some(k) = cache.get(&app_id) {
|
if let Some((ts, k)) = cache.get(&app_id) {
|
||||||
return Ok(Some(k.clone()));
|
if ts.elapsed() <= KEY_CACHE_TTL {
|
||||||
|
return Ok(Some(k.clone()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let key = self
|
let key = self
|
||||||
@@ -65,11 +73,21 @@ impl RealtimeAuthorityImpl {
|
|||||||
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?;
|
||||||
if let Some(ref k) = key {
|
if let Some(ref k) = key {
|
||||||
if let Ok(mut cache) = self.key_cache.lock() {
|
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)
|
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]
|
#[async_trait]
|
||||||
|
|||||||
Reference in New Issue
Block a user