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:
MechaCat02
2026-06-07 20:42:08 +02:00
parent f4189fc52f
commit 4923fa89e3

View File

@@ -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]