From daa46377a0e1e8436c86ffd3610997e803b066d9 Mon Sep 17 00:00:00 2001 From: bluejayA Date: Tue, 12 May 2026 20:12:21 +0900 Subject: [PATCH 1/2] feat(BL-P2-093): live-sync actor_ctx.user_id on cloud switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BL-P2-085 Phase 7 wired actor_ctx.cloud through the shared RwLock so the worker's cross-project block audit picks up runtime cloud switches, but left actor_ctx.user_id pinned to the wire-startup wire_username. In a multi-cloud rescope flow, every audit entry emitted after the switch attributed the block to the previous user_id — and because fingerprint v1 canonical includes `user`, dedup also broke (the same actor across two clouds produced two distinct fingerprints). This change threads the Keystone token's `user.id` UUID through: - Token.user_id: new field, populated by parse_token from the Keystone response's optional `user` block (empty string fallback when missing). - AppEvent::ContextChanged.user_id: carried alongside target so the handler in App::handle_event refreshes the shared actor_ctx. - Empty user_id is treated as "no change" so legacy fixtures, mocks, and rescope paths that lack a user block don't overwrite a valid prior value. - main.rs now prefers the Keystone UUID at startup over wire_username (still falls back to "unknown" if both are unavailable). TDD: added test_context_changed_updates_actor_user_id covering both cloud and user_id propagation through the ContextChanged handler. test_parse_token_no_catalog now asserts the empty-fallback behavior; test_parse_token_from_keystone_response asserts the populated path. cargo test = 1493 passed (1492 → +1). clippy clean. fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adapter/auth/keystone.rs | 24 ++++++ .../auth/keystone_project_directory.rs | 1 + src/adapter/auth/rescope.rs | 1 + src/adapter/auth/scoped_session.rs | 1 + src/adapter/auth/token_cache.rs | 1 + src/adapter/auth/token_scope_fingerprint.rs | 1 + src/app.rs | 85 +++++++++++++++++-- src/context/history.rs | 1 + src/context/resolver.rs | 1 + src/context/state_machine.rs | 1 + src/context/switcher.rs | 1 + src/event.rs | 10 +++ src/main.rs | 17 ++-- src/port/mock_context.rs | 1 + src/port/types.rs | 6 ++ tests/devstack_directory.rs | 1 + 16 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/adapter/auth/keystone.rs b/src/adapter/auth/keystone.rs index c3d256d..64f96b5 100644 --- a/src/adapter/auth/keystone.rs +++ b/src/adapter/auth/keystone.rs @@ -30,6 +30,9 @@ struct KeystoneTokenBody { project: Option, roles: Vec, catalog: Option>, + /// Keystone v3 includes a `user` block with the authenticated user's UUID. + /// Optional so legacy fixtures without it deserialize cleanly. + user: Option, } #[derive(Debug, Deserialize)] @@ -39,6 +42,11 @@ struct KeystoneProject { domain: KeystoneDomain, } +#[derive(Debug, Deserialize)] +struct KeystoneUser { + id: String, +} + #[derive(Debug, Deserialize)] struct KeystoneDomain { id: String, @@ -111,12 +119,15 @@ pub(super) fn parse_token(token_id: String, resp: KeystoneTokenResponse) -> Toke }) .collect(); + let user_id = body.user.map(|u| u.id).unwrap_or_default(); + Token { id: token_id, expires_at: body.expires_at, project, roles, catalog, + user_id, } } @@ -608,6 +619,11 @@ mod tests { r#"{ "token": { "expires_at": "2099-12-31T23:59:59.000000Z", + "user": { + "id": "user-uuid-abc", + "name": "admin", + "domain": { "id": "default", "name": "Default" } + }, "project": { "id": "proj-123", "name": "admin-project", @@ -680,6 +696,10 @@ mod tests { assert_eq!(token.id, "tok-abc-123"); assert_eq!(token.project.name, "admin-project"); assert_eq!(token.project.domain_name, "Default"); + assert_eq!( + token.user_id, "user-uuid-abc", + "user.id must be parsed from Keystone response (BL-P2-093)" + ); assert_eq!(token.roles.len(), 2); assert_eq!(token.roles[0].name, "admin"); assert_eq!(token.catalog.len(), 2); @@ -705,6 +725,9 @@ mod tests { assert!(token.catalog.is_empty()); assert_eq!(token.roles.len(), 1); assert!(token.project.id.is_empty()); + // BL-P2-093: missing `user` block must deserialize cleanly and leave + // user_id empty (caller falls back to wire-startup username). + assert!(token.user_id.is_empty()); } #[test] @@ -843,6 +866,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::new(), + user_id: String::new(), } } diff --git a/src/adapter/auth/keystone_project_directory.rs b/src/adapter/auth/keystone_project_directory.rs index 8c17c4d..c637271 100644 --- a/src/adapter/auth/keystone_project_directory.rs +++ b/src/adapter/auth/keystone_project_directory.rs @@ -330,6 +330,7 @@ mod tests { }, roles: Vec::::new(), catalog: Vec::::new(), + user_id: String::new(), } } diff --git a/src/adapter/auth/rescope.rs b/src/adapter/auth/rescope.rs index 13b3a95..6f8011c 100644 --- a/src/adapter/auth/rescope.rs +++ b/src/adapter/auth/rescope.rs @@ -213,6 +213,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::new(), + user_id: String::new(), } } diff --git a/src/adapter/auth/scoped_session.rs b/src/adapter/auth/scoped_session.rs index f52df92..ac5b9f9 100644 --- a/src/adapter/auth/scoped_session.rs +++ b/src/adapter/auth/scoped_session.rs @@ -167,6 +167,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), } } diff --git a/src/adapter/auth/token_cache.rs b/src/adapter/auth/token_cache.rs index 425ce4d..05790c6 100644 --- a/src/adapter/auth/token_cache.rs +++ b/src/adapter/auth/token_cache.rs @@ -223,6 +223,7 @@ mod tests { url: "https://nova:8774/v2.1".to_string(), }], }], + user_id: String::new(), } } diff --git a/src/adapter/auth/token_scope_fingerprint.rs b/src/adapter/auth/token_scope_fingerprint.rs index 74c894e..d98f550 100644 --- a/src/adapter/auth/token_scope_fingerprint.rs +++ b/src/adapter/auth/token_scope_fingerprint.rs @@ -58,6 +58,7 @@ mod tests { }, roles: Vec::::new(), catalog: Vec::::new(), + user_id: String::new(), } } diff --git a/src/app.rs b/src/app.rs index 92a261d..fd9ec71 100644 --- a/src/app.rs +++ b/src/app.rs @@ -632,7 +632,11 @@ impl App { // data starts loading immediately. // The remaining UX bits (router/selection reset + toast) are tracked // by BL-P2-052 Part B leftovers. - if let AppEvent::ContextChanged { ref target } = event { + if let AppEvent::ContextChanged { + ref target, + ref user_id, + } = event + { // Codex review 3차 P1: async `ContextChanged` can arrive while // the user is in Form/Command mode. The module reset below // leaves the form behind, but without normalizing `input_mode` @@ -645,14 +649,19 @@ impl App { if let Some(cache) = &self.directory_cache { cache.invalidate_cloud(&target.cloud); } - // BL-P2-085 Phase 7 폴리싱: the worker reads `actor_ctx` live at - // each block emit. Without this update, audit entries from the - // worker stay anchored to the spawn-time cloud after the user - // switches. + // BL-P2-085 Phase 7 폴리싱 + BL-P2-093: the worker reads + // `actor_ctx` live at each block emit. Without this update, audit + // entries from the worker stay anchored to the spawn-time cloud + // / user after the user switches. Empty `user_id` is treated as + // "no change" so test fixtures and rescope paths that lack a + // Keystone user UUID don't overwrite a valid prior value. if let Some(ref ctx) = self.actor_ctx && let Ok(mut guard) = ctx.write() { guard.cloud = target.cloud.clone(); + if !user_id.is_empty() { + guard.user_id = user_id.clone(); + } } self.context_indicator.set_target(target, true); for component in self.components.values_mut() { @@ -1698,9 +1707,14 @@ impl App { tokio::spawn(async move { match switcher.switch(request).await { Ok((epoch, snapshot)) => { + // BL-P2-093: forward the rescoped token's Keystone user + // UUID so `handle_event(ContextChanged)` can refresh + // `actor_ctx.user_id` for subsequent audit entries. + let user_id = snapshot.token.user_id.clone(); let _ = event_tx.send(crate::context::VersionedEvent::new( AppEvent::ContextChanged { target: snapshot.target, + user_id, }, epoch, )); @@ -1737,9 +1751,11 @@ impl App { tokio::spawn(async move { match switcher.switch_back().await { Ok((epoch, snapshot)) => { + let user_id = snapshot.token.user_id.clone(); let _ = event_tx.send(crate::context::VersionedEvent::new( AppEvent::ContextChanged { target: snapshot.target, + user_id, }, epoch, )); @@ -2116,6 +2132,7 @@ mod tests { project_name: "admin".into(), domain: "default".into(), }, + user_id: String::new(), }); let t = app .context_indicator @@ -2127,6 +2144,41 @@ mod tests { assert!(app.context_indicator.is_highlighting()); } + #[test] + fn test_context_changed_updates_actor_user_id() { + // BL-P2-093: Phase 7 폴리싱이 actor_ctx.cloud는 live 갱신하지만 user_id는 + // wire-startup 시점 값에 고정. 다른 자격증명으로 재인증한 후 발생한 모든 + // cross-project block audit이 이전 user_id로 기록되어 attribution을 망가뜨림. + // ContextChanged payload의 user_id는 handle_event 안에서 actor_ctx에 반영 + // 되어야 한다. + use crate::context::types::ContextTarget; + use crate::worker::ActorContext; + + let actor_ctx = Arc::new(std::sync::RwLock::new(ActorContext { + cloud: "cloud-A".into(), + user_id: "user-A".into(), + })); + let mut app = make_app(); + app.set_actor_ctx(actor_ctx.clone()); + + app.handle_event(AppEvent::ContextChanged { + target: ContextTarget { + cloud: "cloud-B".into(), + project_id: "p1".into(), + project_name: "admin".into(), + domain: "default".into(), + }, + user_id: "user-B".into(), + }); + + let guard = actor_ctx.read().expect("actor_ctx read"); + assert_eq!(guard.cloud, "cloud-B", "cloud must follow ContextChanged"); + assert_eq!( + guard.user_id, "user-B", + "user_id must follow ContextChanged (BL-P2-093)" + ); + } + #[test] fn test_context_changed_resets_input_mode_to_normal() { // Codex review 3차 P1: async `ContextChanged` can arrive while the @@ -2144,6 +2196,7 @@ mod tests { project_name: "admin".into(), domain: "default".into(), }, + user_id: String::new(), }); assert_eq!( app.input_mode, @@ -2216,6 +2269,7 @@ mod tests { project_name: "admin".into(), domain: "default".into(), }, + user_id: String::new(), }); assert!( state.borrow().last_recently, @@ -2274,6 +2328,7 @@ mod tests { project_name: "admin".into(), domain: "default".into(), }, + user_id: String::new(), }); let mut received: Vec = Vec::new(); @@ -3586,6 +3641,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), }; let mut new_token = old_token.clone(); new_token.id = "new".into(); @@ -3634,9 +3690,16 @@ mod tests { .expect("channel closed"); assert_eq!(envelope.epoch(), 1); match envelope.into_inner() { - AppEvent::ContextChanged { target } => { + AppEvent::ContextChanged { target, user_id } => { assert_eq!(target.project_name, "demo"); assert_eq!(target.cloud, "devstack"); + // BL-P2-093: MockContextSession's rescoped token leaves + // user_id blank — `handle_event` treats empty as "no change", + // preserving the wire-startup actor_ctx.user_id. + assert!( + user_id.is_empty(), + "mock rescoped token carries empty user_id by design" + ); } other => panic!("expected ContextChanged, got {other:?}"), } @@ -3761,7 +3824,10 @@ mod tests { project_name: "admin".into(), domain: "default".into(), }; - app.handle_event(AppEvent::ContextChanged { target }); + app.handle_event(AppEvent::ContextChanged { + target, + user_id: String::new(), + }); // "devstack" entries should be gone; "prod" should remain. assert!( @@ -3786,7 +3852,10 @@ mod tests { project_name: "admin".into(), domain: "default".into(), }; - app.handle_event(AppEvent::ContextChanged { target }); + app.handle_event(AppEvent::ContextChanged { + target, + user_id: String::new(), + }); // If we reach here without panicking, the test passes. } } diff --git a/src/context/history.rs b/src/context/history.rs index c32750f..d5e80d3 100644 --- a/src/context/history.rs +++ b/src/context/history.rs @@ -62,6 +62,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), }; ContextSnapshot { target: target.clone(), diff --git a/src/context/resolver.rs b/src/context/resolver.rs index 1335af0..ff347a2 100644 --- a/src/context/resolver.rs +++ b/src/context/resolver.rs @@ -822,6 +822,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), }) } async fn set_active(&self, _scope: TokenScope, _token: Token) -> Result<(), SwitchError> { diff --git a/src/context/state_machine.rs b/src/context/state_machine.rs index 0dc005b..4cb69ab 100644 --- a/src/context/state_machine.rs +++ b/src/context/state_machine.rs @@ -223,6 +223,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), }, token_scope: TokenScope::from(&t), captured_at: Utc.with_ymd_and_hms(2026, 4, 14, 0, 0, 0).unwrap(), diff --git a/src/context/switcher.rs b/src/context/switcher.rs index ee40e7a..41fc37d 100644 --- a/src/context/switcher.rs +++ b/src/context/switcher.rs @@ -287,6 +287,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), } } diff --git a/src/event.rs b/src/event.rs index a1f5e89..7528e0b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -210,8 +210,18 @@ pub enum AppEvent { /// this and clear cached data so the user never sees stale rows. /// Epoch lives on the surrounding `VersionedEvent` envelope, not the /// payload — see `crate::context::VersionedEvent`. + /// + /// BL-P2-093: `user_id` carries the Keystone-issued UUID for the freshly + /// rescoped token. `App::handle_event` writes it into the shared + /// `ActorContext` so subsequent cross-project block audits attribute the + /// new actor — without this, multi-cloud reauth leaves audit entries + /// anchored to the wire-startup user and breaks fingerprint v1 dedup + /// (canonical `"v1|user|active|origin|target|action|resource_id"`). + /// Empty string falls back to the previous value (kept for adapters/tests + /// that lack user info; production tokens always populate it). ContextChanged { target: ContextTarget, + user_id: String, }, } diff --git a/src/main.rs b/src/main.rs index 9f0fd3a..caf7818 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,9 +151,12 @@ async fn main() -> Result<(), Box> { // Trigger initial authentication, then initialize RBAC from token roles // (the `rbac` Arc was constructed earlier so ActionSender already holds // a clone for FR2 stamping — `update_roles` here is observed live by - // the sender's `ScopeProvider` impl). + // the sender's `ScopeProvider` impl). The token's Keystone user UUID is + // captured here for BL-P2-093 startup `actor_ctx.user_id` wiring. let _ = auth_provider.get_token().await; // force auth before reading roles + let mut initial_token_user_id = String::new(); if let Ok(token) = auth_provider.get_token_info().await { + initial_token_user_id = token.user_id.clone(); rbac.update_roles(token.roles, Some(token.project.id)); } let mut module_registry = nexttui::registry::ModuleRegistry::new(); @@ -165,12 +168,14 @@ async fn main() -> Result<(), Box> { // both worker-side block events and app-side success entries land in // a single rotated log. `actor_ctx` lives behind an `Arc>` // so a runtime cloud-switch (BL-P2-074) updates the next audit entry - // — `App::handle_event` writes the new cloud on `ContextChanged`. - // `user_id` falls back to `"unknown"` (matches `App::build_audit_entry`) - // when the credential lacks an explicit username; a follow-up resolves - // the Keystone UUID from the live `Token`. + // — `App::handle_event` writes the new cloud and user_id on + // `ContextChanged` (BL-P2-093). When the Keystone token exposes a + // `user.id`, prefer that UUID; otherwise fall back to the configured + // wire username for legacy parity with `App::build_audit_entry`. let audit_logger = app.audit_logger_arc(); - let initial_user_id = if wire_username.is_empty() { + let initial_user_id = if !initial_token_user_id.is_empty() { + initial_token_user_id + } else if wire_username.is_empty() { "unknown".to_string() } else { wire_username.clone() diff --git a/src/port/mock_context.rs b/src/port/mock_context.rs index dfcbab5..9db710d 100644 --- a/src/port/mock_context.rs +++ b/src/port/mock_context.rs @@ -337,6 +337,7 @@ mod tests { }, roles: Vec::new(), catalog: Vec::::new(), + user_id: String::new(), } } diff --git a/src/port/types.rs b/src/port/types.rs index ea115d3..1ab701a 100644 --- a/src/port/types.rs +++ b/src/port/types.rs @@ -87,6 +87,11 @@ pub struct Token { pub project: ProjectScope, pub roles: Vec, pub catalog: Vec, + /// Keystone-issued user UUID extracted from the token response's `user.id`. + /// Empty when the response lacks a user block (legacy fixtures, mocks); + /// callers fall back to the wire-startup username in that case. + #[serde(default)] + pub user_id: String, } impl std::fmt::Debug for Token { @@ -97,6 +102,7 @@ impl std::fmt::Debug for Token { .field("project", &self.project) .field("roles", &self.roles) .field("catalog", &format!("[{} entries]", self.catalog.len())) + .field("user_id", &self.user_id) .finish() } } diff --git a/tests/devstack_directory.rs b/tests/devstack_directory.rs index b9cb233..c279528 100644 --- a/tests/devstack_directory.rs +++ b/tests/devstack_directory.rs @@ -126,6 +126,7 @@ fn make_devstack_token(id: &str) -> Token { }, roles: Vec::::new(), catalog: Vec::::new(), + user_id: String::new(), } } From e921ac172a27155275ff1091967558a99910e623 Mon Sep 17 00:00:00 2001 From: bluejayA Date: Tue, 12 May 2026 20:29:46 +0900 Subject: [PATCH 2/2] fix(BL-P2-093): skip legacy disk-cached tokens missing user_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review P2: `Token.user_id` deserializes to `""` via `#[serde(default)]` for tokens cached before BL-P2-093. `KeystoneAuthAdapter::new` loads those straight into `token_map`, so a post-upgrade switch into the cached scope emits `AppEvent::ContextChanged` with an empty `user_id` and `App::handle_event` preserves the prior `actor_ctx.user_id` — defeating the live-sync invariant the rest of this PR establishes. `load_token_file` now treats `user_id.is_empty()` as a cache miss so the next `get_token` reauths against Keystone, parses the fresh `user.id`, and overwrites the file via the existing `save_token` path on the auth-success arm. The legacy file is intentionally NOT auto-deleted (only expired tokens are) so a transient reauth failure doesn't wipe a still-valid catalog/roles payload that was recoverable. Tests: bumped `sample_token` user_id to a non-empty fixture, added `test_load_legacy_token_without_user_id_treated_as_cache_miss` covering both the skip and the no-auto-delete behavior. cargo test = 1494 passed (+1). clippy clean. fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adapter/auth/token_cache.rs | 56 +++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/src/adapter/auth/token_cache.rs b/src/adapter/auth/token_cache.rs index 05790c6..07abaaa 100644 --- a/src/adapter/auth/token_cache.rs +++ b/src/adapter/auth/token_cache.rs @@ -121,19 +121,36 @@ pub fn save_token( } /// Load a single token from a cache file. -/// Returns None if the file doesn't exist, is unreadable, or the token is expired. +/// Returns None if the file doesn't exist, is unreadable, the token is +/// expired, or the token predates BL-P2-093 (missing `user_id`). /// Automatically deletes expired token files. fn load_token_file(path: &Path) -> Option { let data = std::fs::read(path).ok()?; let token: Token = serde_json::from_slice(&data).ok()?; - if token.expires_at > chrono::Utc::now() + chrono::Duration::minutes(1) { - Some(token) - } else { + if token.expires_at <= chrono::Utc::now() + chrono::Duration::minutes(1) { tracing::info!(path = %path.display(), "cached token expired, removing"); let _ = std::fs::remove_file(path); - None + return None; + } + + // BL-P2-093 upgrade safety: legacy disk-cached tokens (pre-BL-P2-093) + // deserialize with `user_id = ""` via `#[serde(default)]`. Accepting + // them would let `actor_ctx.user_id` stay anchored to the wire username + // when the user switches into that cached scope post-upgrade (the + // ContextChanged emit forwards the empty `user_id` and `handle_event` + // preserves the prior value). Treat empty as a cache miss so the + // next `get_token` reauths against Keystone and persists the UUID; + // the file is left in place to be overwritten by the fresh token. + if token.user_id.is_empty() { + tracing::info!( + path = %path.display(), + "cached token lacks user_id (pre-BL-P2-093), forcing reauth" + ); + return None; } + + Some(token) } /// Load all valid cached tokens from the cache directory. @@ -223,7 +240,7 @@ mod tests { url: "https://nova:8774/v2.1".to_string(), }], }], - user_id: String::new(), + user_id: "user-uuid-cached".to_string(), } } @@ -344,6 +361,33 @@ mod tests { assert!(loaded.is_empty()); } + #[test] + fn test_load_legacy_token_without_user_id_treated_as_cache_miss() { + // BL-P2-093 upgrade safety: pre-upgrade cached tokens deserialize + // with `user_id = ""`. `load_token_file` must reject them so the + // next get_token reauths and persists the Keystone UUID instead of + // leaving actor_ctx.user_id anchored to the wire username. + let dir = TempDir::new().unwrap(); + let cache_dir = dir.path().join("cloud-legacy"); + let scope = sample_scope(); + + let mut token = sample_token(60); + token.user_id = String::new(); + save_token(&token, &cache_dir, &scope).unwrap(); + + let loaded = load_all_tokens(&cache_dir); + assert!( + loaded.is_empty(), + "legacy empty user_id must be treated as a cache miss" + ); + // File is left in place; next save_token will overwrite with a + // fresh token carrying the Keystone UUID. + assert!( + cache_dir.join(scope.cache_key()).exists(), + "legacy file must NOT be auto-deleted (only expired tokens are)" + ); + } + #[test] fn test_load_corrupt_file_skipped() { let dir = TempDir::new().unwrap();