feat(BL-P2-085): cross-project scoping atomic security (FR1~FR4)#85
Merged
Conversation
…rror variant
Cross-project scoping의 정책/에러 기반층(Phase 1~2 of 11)을 도입한다.
표면 hotfix(PR#82)가 차단한 폼 단의 위험 외에도 worker mutation/RBAC project-scope/
adapter filter 누출이 남아 있어 4겹 구조적 fix를 시작한다. Phase 1은 순수 정책 모듈,
Phase 2는 사용자 노출 에러 variant를 도입.
Phase 1 — Foundation (pure, no dependencies):
- Step 1: src/infra/cross_project_guard.rs — CrossProjectGuard pure functions
- check_origin_scope / check_form_selection
- GuardDecision { Allow, Block { reason } }
- CrossProjectReason (4 variants: OriginScopeMismatch, FormSelectionMismatch,
AdapterFilterViolation, UnscopedFailSafe) + as_str() audit-stable strings
- GuardLayer (4 variants: Fr1Adapter, Fr2Worker, Fr3Rbac, Fr4Form) + as_str()
- 9 unit tests
- Step 2: src/action.rs — DispatchedAction envelope
- { action: Action, origin_project_id: Option<String> }
- stamped()/unstamped()/is_stamped() helpers
- 2 unit tests
Phase 2 — Error variant:
- Step 3: src/error.rs — AppError::CrossProjectBlocked
- { reason: CrossProjectReason, guard_layer: GuardLayer }
- thiserror display: "Cross-project operation blocked: {reason} (layer: {layer})"
- reason.as_str() / guard_layer.as_str() 안정 문자열 사용 (audit 일관성)
- 1 unit test (origin/form 두 시나리오)
Tests: 1370 → 1382 (+12) all green, clippy -D warnings clean.
이번 commit은 INCEPTION 산출물(workspace/requirements/workflow-plan/application-design +
design-review-raw codex/gemini/synthesis), CONSTRUCTION code-plan(18 Steps × 11 Phases),
그리고 stale session artifacts 정리(bl-p2-074/build-and-test/requirements-review-raw)도 함께 포함.
다음 단계 = Phase 3 Step 4 — CrossProjectBlockEvent builder + 기존 AuditLogger 통합
(src/infra/cross_project_audit.rs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r 통합
Cross-project block 이벤트 구조화 + 기존 production AuditLogger 재사용. 신규 audit
서브시스템 도입을 회피하고 rotation/sensitive masking 인프라를 그대로 활용한다.
Phase 3 — Audit infrastructure:
- Step 4: src/infra/cross_project_audit.rs (신규)
- CrossProjectBlockEvent (13 fields):
timestamp / actor_user_id / actor_cloud / active_project_id /
asserted_origin_project_id / target_project_id / action_type /
resource_kind / resource_id / resource_name / reason / guard_layer /
correlation_id
- to_audit_entry() — 기존 AuditEntry 형태로 매핑.
timestamp는 to_rfc3339() (AuditEntry는 String 필드).
resource_id None → empty string (AuditEntry는 String, NOT Option).
필드 rename: action_type→action, resource_kind→resource_type.
details JSON에 fingerprint/guard_layer/correlation_id/asserted_origin/target packing.
result = AuditResult::Failed("cross_project_block:<reason as_str>")
- fingerprint() — v1 canonical (LOCKED schema):
"v1|user|active|origin|target|action|resource_id" → sha256[..6] → 12 hex.
임의 변경 시 v2로 bump하고 분석 도구 마이그레이션 필수.
sha256 선택 이유: std DefaultHasher는 Rust 버전 간 안정성 보장 X.
- emit(event, Option<&AuditLogger>) — best-effort.
logger 있으면 log_entry 시도 → Err 시 tracing fallback.
logger 없으면 tracing::warn! 직접.
절대 panic 안 함.
- 8 unit tests (field mapping / details JSON / Failed reason /
fingerprint canonical 재유도 / boundary collision-free /
None resource_id 일관성 / emit-with-logger 파일 검증 / emit-without-logger 무패닉)
Dep:
- sha2 = "0.10" 추가 (RustCrypto 표준, ~50KB, fingerprint v1 schema 안정성)
Module registration:
- src/infra/mod.rs: pub mod cross_project_audit
Plan/검증:
- Step 4 진입 전 src/infra/audit.rs 시그니처 사전 점검으로 plan 가정 4건
(timestamp 타입 / resource_id Option 여부 / 필드 rename / sha2 dep 부재)
RED 작성 단계에서 정확히 매핑 — RED가 잘못된 이유로 깨지는 낭비 회피
- 1382 → 1390 (+8), clippy -D warnings clean
Refs:
- Plan: devflow-docs/construction/bl-p2-085/code-plan.md (Phase 3)
- Phase 1+2 review (clean): devflow-docs/construction/bl-p2-085/review-phase1-2-codex.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review (--scope branch --base ca2ec2a) flagged that emit()'s success branch returned immediately without invoking AuditLogger::rotate_if_needed, diverging from the established App::record_audit pattern (src/app.rs:780-793). Under sustained cross-project block events the audit.log would grow past MAX_LOG_SIZE (10MB) without rotation kicking in. Fix: - src/infra/cross_project_audit.rs: emit() success branch now calls logger.rotate_if_needed() and tracing::warn! on Err (best-effort, never propagates). - Doc comment references App::record_audit so future readers see the parity contract. Verification: - 1390 tests stable (5/5 runs green after fix). - clippy -D warnings clean. - No new tests added — rotation only triggers at MAX_LOG_SIZE; parity with the existing call site is the contract being enforced. Existing emit-with- logger test continues to cover the success path. Refs: - Review artifact: devflow-docs/construction/bl-p2-085/review-step4-codex.md - Pattern source: src/app.rs:780-793 (record_audit) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…preserve project
FR3(RBAC project-scope guard)와 token 재발급 시 scope 보존 메서드를 도입한다.
역할 단(can_perform)과 scope 단(target == active project_id)을 통합 판단해서
audit/toast가 거부 사유를 명확히 구분할 수 있게 한다.
Phase 4 — RBAC 확장:
- Step 5: src/infra/rbac.rs
- RbacScopeDecision { Allow, Deny { reason: RbacDenialReason } } 신규 enum
- RbacDenialReason { RoleTier, ProjectScope, Both } + as_str()
"role_tier" / "project_scope" / "both" — audit details에 안정 문자열로 기록
- RbacGuard::check_project_scope(target, action) -> RbacScopeDecision
role_ok = self.can_perform(action)
scope_ok = self.project_id().is_some_and(|p| p == target)
None scope = fail-safe ProjectScope deny (별도 variant 없이 통합 처리)
- 7 unit tests:
admin match → Allow / admin mismatch → ProjectScope /
unscoped admin → ProjectScope (fail-safe) /
reader Create match → RoleTier / reader Create mismatch → Both /
member ForceDelete match → RoleTier / reader Read match → Allow
- Step 6: src/infra/rbac.rs
- RbacGuard::update_roles_preserve_project(roles)
self.state.write() 잡고 roles + effective_role 갱신,
capabilities.clear() (update_roles와 parity — caller가 update_capabilities
로 재공급하는 가정), project_id 보존.
Use case: Keystone token 재발급 시 같은 project scope 유지.
- 4 unit tests:
project_id 보존 / roles+effective 갱신 / 기존 update_roles는 여전히
project 갱신(회귀방지) / capabilities clear parity
검증:
- 1390 → 1401 (+11), clippy -D warnings clean
- 사전 audit (rbac.rs 5분): RwLock 패턴 / can_perform 매트릭스 / TokenRole role(name)
test helper — plan 가정 모두 정확. RED 작성 단계에서 정확 매핑.
Refs:
- Plan: devflow-docs/construction/bl-p2-085/code-plan.md (Phase 4)
- 사용처는 Phase 6 worker hook (Step 11) + Phase 9 form validation (Step 16)에서 wire-up
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…is_mutation
Action enum의 새 variant가 추가될 때 RBAC 분류를 silent miss할 수 있던 fallthrough
match (`_ => None`)를 제거하고, 모든 non-mutation variant를 명시한다. 컴파일러가
exhaustive match를 강제하므로 향후 Action 추가 시 컴파일 시점에 분류 결정을 강제.
Phase 5 — Action 분류 exhaustive:
- src/worker.rs::action_to_kind
- `_ => None` fallthrough 제거.
- 28개 None variant 명시 (4 카테고리):
* Read-only: 18 Fetch* variants (FetchServers, FetchFlavors, FetchAggregates,
FetchComputeServices, FetchHypervisors, FetchNetworks, FetchSecurityGroups,
FetchFloatingIps, FetchSubnets, FetchAgents, FetchVolumes, FetchSnapshots,
FetchImages, FetchProjects, FetchUsers, FetchUsage, FetchMigrationProgress,
FetchPorts)
* UI/Nav: Navigate, Back, FocusSidebar, EnterFormMode, ExitFormMode,
SelectResource, NavigateToResource, ShowToast
* System: RefreshAll, Quit, ToggleAllTenants
* Context switch (orchestration, not RBAC): SwitchContext, SwitchBack
- visibility를 fn → pub(crate) fn 으로 승격 (Phase 6 ActionSender가 사용 예정).
- doc comment 추가 — exhaustive 강제 의도와 BL-P2-085 Step 7 출처 명시.
- src/worker.rs::action_is_mutation (신규)
- pub(crate) wrapper: action_to_kind(action).is_some()
- 사용처: ActionSender (Phase 6 Step 9)가 mutation일 때만 origin_project_id 스탬핑.
- 현재는 caller 부재로 #[allow(dead_code)] — 의도된 임시 상태, Step 9에서 wire.
5 unit tests (worker::tests):
- test_action_to_kind_fetch_and_nav_variants_return_none — 28 variants 일괄
- test_action_to_kind_all_mutations_have_kind — 38 mutation variants 일괄
- test_action_to_kind_rbac_mapping_lockstep — 11 (mutation × ActionKind) 명시 매핑
(Create/Delete/ForceDelete/Resize/Migrate/Evacuate/EnableDisable/ManageQuota/
Attach/Detach 모든 ActionKind 커버)
- test_action_is_mutation_helper_parity — 래퍼 일관성
- test_action_to_kind_switch_context_returns_none — orchestration 분리 명시
검증:
- 1401 → 1406 (+5), clippy -D warnings clean.
- 사전 audit (worker.rs/action.rs/port::types 5분): param shape mismatch 13건 RED 단계
에서 즉시 수정. 사전 audit이 RED 신호를 흐리는 것을 방지하는 효과 재확인.
다음 단계 (BL-P2-085 Phase 6 — 가장 침습적):
- Step 8: ScopeProvider trait + mock
- Step 9: ActionSender 타입 교체 (UnboundedSender<Action> → UnboundedSender<VersionedEvent<DispatchedAction>>)
- Step 10: app.rs + 테스트 헬퍼 일괄 갱신
Refs:
- Plan: devflow-docs/construction/bl-p2-085/code-plan.md (Phase 5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…6 진입 전 일시 정지) audit.md에 다음 메타데이터 append: - session-end: phase=CONSTRUCTION, last=Step-7, next=Phase-6-Step-8, tests=1406 - cross-session-handoff: resume instructions (worktree cd / HEAD verify / memory read / optional codex review / Phase 6 진입 시 사용자 확인 필요) - flake-tracking: keystone_project_directory::max_pages_cap_trips_error (BL-P2-080 영역, ~1/8 runs, BL-P2-086 신규 등록 권장) 소스 변경 없음. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`RbacGuard::check_project_scope` previously called `can_perform()` and
`project_id()` separately, acquiring the RwLock read guard twice. A
concurrent `update_roles` / `update_roles_preserve_project` (token
refresh, context switch) could interleave between the two reads and
yield `(role_ok, scope_ok) = (true, true)` for a state where neither
consistent snapshot would have allowed the action — an authorization
bypass surface.
Fix: introduce private `RbacState::scope_decision(target, action)` that
operates on `&self` field reads. `check_project_scope` now acquires the
read guard once and delegates, so role and project_id come from the
same snapshot. Poisoned lock falls back to `Deny { Both }` (fail-safe).
- Decision logic: unchanged (single-threaded behavior identical, all 7
existing `test_check_project_scope_*` pass).
- New test `test_rbac_state_scope_decision_atomic_snapshot` exercises
`RbacState::scope_decision` directly — locks in the snapshot-based
contract so future refactors cannot regress.
- 1406 → 1407 tests passing. cargo clippy -D warnings clean.
- Schema-stable: no audit event format change, no public API change.
Follow-up to commit 626cd64 (Phase 4 Step 5+6). Reviewed by Codex
GPT-5 — 2026-04-28-bl-p2-085-phase4-5-rbac-action-feat-bl-p2-085-codex.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit log additions for the 2026-04-28 resume session: - staleness check skipped (upstream unset on worktree branch) - devflow-state.md re-synced (stale Phase 1 entry → Phase 5 done) - /codex:review --scope branch --base 53d7292 run (1 P1: rbac.rs race) - P1 hotfix applied in commit b4b4c44 (1407 tests, clippy clean) No code change — devflow bookkeeping only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the read-only scope abstraction that ActionSender (Step 9)
will use to stamp DispatchedAction.origin_project_id at send time.
- `pub trait ScopeProvider: Send + Sync { fn current_project_id(&self) -> Option<String>; }`
Lives next to ActionSender in `src/context/action_channel.rs`. Designed
for trait-object storage (`Arc<dyn ScopeProvider>`) so it can be cloned
across worker tasks while remaining concurrent-safe.
- `impl ScopeProvider for Arc<RbacGuard>` — the production implementation.
Delegates to `RbacGuard::project_id()`, which now returns from a single
RwLock snapshot (Codex P1 atomicity contract preserved end-to-end).
- The trait contract requires *live* reads (not captured snapshots), so
scope changes between Sender construction and `send()` must propagate.
`test_scope_provider_reflects_post_update_change` locks this in.
Tests: 1407 → 1410 (+3 ScopeProvider tests). cargo clippy -D warnings clean.
No public API change to ActionSender yet — that lands in Step 9 when the
sender accepts `Arc<dyn ScopeProvider>` and stamps DispatchedAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stop checklist commit on the correct branch (per feedback_branch_policy.md and feedback_stop_checklist.md). Audit log entries: - codex-review-rerun (post-P1-hotfix verdict=approved) - phase6-step8-complete (commit 73b347d, tests 1410, clippy clean) - session-end (next resume = Phase 6 Step 9, 8 commits clean baseline) - cross-session-handoff (resume instructions) - system-followup-bl (BL-097 in devflow-aidlc-like — issue #189, PR #190) - main-revert-incident (transparency: an earlier markers commit landed on main by mistake; reverted on main as bec4c05 and re-applied here) Code-plan: Step 8 marked [x] (Phase 6 Step 8 ScopeProvider trait done). Next resume point: Phase 6 Step 9 — ActionSender 시그니처 교체 + 스탬핑 (Step 10과 묶어 한 사이클 권장; app.rs 다수 호출부 컴파일 에러 도미노 예상). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r 주입
Wire FR2 origin stamping into the central ActionSender so every mutation
Action carries the project_id active at send time. The worker (Step 11)
will reject any DispatchedAction whose origin disagrees with the live
active scope — the structural fix for the cross-project leak diagnosed
in BL-P2-085.
Channel migration:
- `mpsc::UnboundedSender<VersionedEvent<Action>>` →
`mpsc::UnboundedSender<VersionedEvent<DispatchedAction>>`
- Same for the matching receiver. `ActionReceiver::recv` keeps its
`Option<Action>` external signature by stripping `.action` internally,
so all ~100 module test sites using `test_action_channel()` compile and
pass unchanged.
ActionSender:
- New 3rd `new` arg: `scope_provider: Arc<dyn ScopeProvider>`.
- `send(action)` keeps its public signature; internally it consults
`crate::worker::action_is_mutation` (Phase 5 Step 7's exhaustive
classification) and `scope_provider.current_project_id()` to wrap the
Action as `DispatchedAction::stamped` (mutation + Some scope) or
`unstamped` (read-only OR unscoped provider).
- Stamping happens at *send time* using the live provider, satisfying
the contract test `test_scope_provider_reflects_post_update_change`.
ScopeProvider trait location refinement:
- Implementation moved from `Arc<RbacGuard>` to `RbacGuard` itself so
`Arc<RbacGuard>` coerces directly to `Arc<dyn ScopeProvider>` via the
standard `Arc<T> → Arc<dyn Trait>` unsized coercion. Step 8 tests
updated to use auto-deref method-call form.
main.rs (Step 10):
- `RbacGuard` Arc constructed up front and shared across `ActionSender`
(FR2 stamping) and `App::from_registry` (RBAC) and `run_worker` (RBAC).
`update_roles` after auth is observed live by the sender's provider.
worker.rs (compat-only Step 10 piece):
- `run_worker` channel type updated to `<DispatchedAction>` and unwraps
`dispatched.action` to keep existing handle_action logic intact.
Step 11 will read `dispatched.origin_project_id` to gate mutations.
- `action_is_mutation` `#[allow(dead_code)]` removed — now wired.
3 RED → GREEN tests added in `context::action_channel::tests`:
- `test_sender_stamps_mutation_with_current_scope` (scope=A → origin=Some("A"))
- `test_sender_leaves_readonly_unstamped` (FetchServers → None)
- `test_sender_handles_unscoped_provider_returns_none_origin` (no roles → None)
Test count: 1410 → 1413 (+3). cargo clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stop checklist commit (per feedback_stop_checklist.md, on the correct worktree branch — branch verified before commit). Audit log entries: - phase6-step9-10-complete (commit 1f80968, tests 1413, clippy clean) - session-end (3rd stop, option D — Step 11 deferred to next session) - cross-session-handoff (resume instructions with branch verify step) - step-11-scope-decision (Step 11 scope ~$2 / 300-500 lines, recommend 3-cycle split 11a signature+hook / 11b audit / 11c toast for token distribution and per-cycle external review opportunity) Code-plan: Step 9 + Step 10 marked [x] (single atomic commit 1f80968). Next resume point: Phase 7 Step 11 — run_worker C5 pseudo-flow + AuditLogger 통합. Recommended sub-split: 11a: signature +3 args (audit_logger / actor_cloud / actor_user_id) + origin guard hook (no audit/toast yet) + 2 tests 11b: AuditLogger integration + CrossProjectBlockEvent::new helper + 1 test (no_audit_logger behavior) 11c: AppEvent::CrossProjectBlocked + generate_toast mapping + 2 tests (toast order + readonly bypass) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `worker::check_dispatched_origin(&DispatchedAction, &RbacGuard)`
helper that reuses `cross_project_guard::check_origin_scope` and wire
it into `run_worker` as a single `if let GuardDecision::Block { .. }
{ continue; }` after envelope split. No signature change, no audit,
no toast — Step 11b will add `AuditLogger::emit` on Block, Step 11c
will add the `AppEvent::CrossProjectBlocked` toast path.
Tests:
- test_worker_allows_mutation_when_origin_matches
- test_worker_blocks_mutation_when_origin_mismatch
Codex review (Phase 6 Step 9+10) verdict=approved before entry —
saved to ~/projects/docs/reviews/2026-04-29-bl-p2-085-phase6-step9-10-feat-bl-p2-085-codex.md.
Tests: 1413 → 1415 pass, clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… block Wire `CrossProjectBlockEvent` emission through the existing `AuditLogger` when the FR2 origin guard rejects a dispatched mutation. Helpers - `CrossProjectBlockEvent::new(reason, layer, action_type, resource_kind, actor_cloud, actor_user_id, active, origin, correlation_id)` — convenience constructor that stamps `Utc::now()` and leaves resource-bound optional fields (`target_project_id`, `resource_id`, `resource_name`) as `None` for caller mutation. - `worker::emit_origin_block_audit(reason, &dispatched, &rbac, logger, cloud, user_id, correlation_id)` — sync helper that builds the event and calls `cross_project_audit::emit`. Best-effort: logger=None falls back to `tracing::warn!`. Wiring - `run_worker` signature gains `audit_logger: Option<Arc<AuditLogger>>`, `actor_cloud: String`, `actor_user_id: String`. The Block branch in the recv loop now calls `emit_origin_block_audit` (correlation_id = `action_epoch`) before `continue`. - `App.audit_logger` switches from `Option<AuditLogger>` to `Option<Arc<AuditLogger>>` and exposes `audit_logger_arc()` so the worker shares the same instance — two parallel `BufWriter`s on the same path would interleave writes. - `main.rs` populates `actor_user_id` from the configured username (matches `App::build_audit_entry`); a follow-up resolves the Keystone UUID and adds context-switch refresh. `resource_kind` is left as "" with an inline TODO — Step 11c follow-up enriches per-action. Reviewer Must-fix #1 (guard_layer + correlation_id) satisfied: events carry `details.guard_layer="fr2_worker"` and `details.correlation_id=<envelope.epoch()>`. Tests - `cross_project_audit::tests::test_new_convenience_constructor_fills_required_fields_and_now_timestamp` - `worker::tests::test_emit_origin_block_audit_writes_entry_when_logger_present` (asserts cloud/user/project/result/details.guard_layer/correlation_id) - `worker::tests::test_emit_origin_block_audit_does_not_panic_when_logger_none` 1415 → 1418 pass, clippy clean. Pre-existing keystone_project_directory flake observed once (retry-pass; BL-P2-086 candidate, not a regression). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oast
Surface worker FR2 origin-block decisions to the UI via a new
`AppEvent::CrossProjectBlocked { reason: String, action: String }`
variant and an Error-level toast (parity with `PermissionDenied`).
Helpers
- `worker::make_cross_project_blocked_event(&CrossProjectReason, &Action)
-> AppEvent` keeps the variant decoupled from `cross_project_guard`
so the UI layer never imports the guard module.
- `App::generate_toast` adds a single arm: "Cross-project block:
{action} ({reason})" → `ToastLevel::Error`. Inherits the longer Error
TTL and shows up in the activity log as a failure entry.
Wiring
- `run_worker` Block path final shape: `emit_origin_block_audit` →
`event_tx.send(VersionedEvent::new(AppEvent::CrossProjectBlocked,
action_epoch))` → `continue`. Audit precedes the UI event so the
structured entry lands even when the receiver has been dropped.
Both happen synchronously before `continue`, so no `ApiError` for
this dispatch can race ahead of the block notification.
Tests
- `worker::tests::test_worker_allows_readonly_without_guard` —
`DispatchedAction::unstamped` skips the origin guard regardless of
`RbacGuard` scope state.
- `worker::tests::test_make_cross_project_blocked_event_carries_reason_and_action`
— helper-level conversion check.
- `app::tests::test_generate_toast_for_cross_project_blocked_pushes_error`
— end-to-end `handle_event` exercise: activity log entry exists,
not-success, message contains the reason slug.
1418 → 1421 pass (single-threaded; pre-existing
`keystone_project_directory` mock-isolation flake observed once
multi-threaded — BL-P2-086 candidate, not a BL-P2-085 regression).
clippy clean.
Phase 7 complete: Step 11 split into 11a (hook) + 11b (audit) + 11c
(toast), 8 new tests total (1413 → 1421).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…back parity Address Codex + cargo-review (Multi-Agent) findings on Phase 7 (Step 11 a/b/c) before entering Phase 8. Correctness MED #1 — main.rs username fallback inconsistency - `actor_user_id` previously fell back to `String::new()` when the configured credential lacked a username, while `App::build_audit_entry` uses `"unknown"`. Worker-emitted audit lines therefore had `user=""` while app-side success entries had `user="unknown"` for the same account. Standardise on `"unknown"` and reuse `wire_username` so `AuthMethod::ApplicationCredential.id` is honoured. Correctness MED #2 — stale actor capture survives cloud-switch - `actor_cloud` and `actor_user_id` were captured as owned `String`s at worker spawn. After a runtime cloud-switch (BL-P2-074) the worker kept stamping audit lines with the spawn-time cloud forever. New `worker::ActorContext { cloud, user_id }` shared as `Arc<RwLock<ActorContext>>` is read at each `emit_origin_block_audit` call, and `App::handle_event` now updates `actor_ctx.cloud` inside the `ContextChanged` arm so the next block reflects the new cloud. Style #9 — variable rename - `audit_logger_for_worker` → `audit_logger` in main.rs (consistent brevity with neighbouring `actor_ctx`). Wiring - `run_worker` signature: drop `actor_cloud: String`/`actor_user_id: String`, take `actor_ctx: Arc<RwLock<ActorContext>>` instead. - `App.actor_ctx` field + `set_actor_ctx()` setter installed from main.rs after construction. Tests - New: `worker::tests::test_emit_origin_block_audit_picks_up_actor_context_mutation` — emit twice, mutate `ActorContext.cloud` between, assert the second audit line carries the post-switch cloud. - Existing 11b emit tests migrated to the `actor_ctx_with` helper. 1421 → 1422 pass (single-threaded; pre-existing BL-P2-086 keystone_project_directory mock flake unaffected). Clippy clean. Follow-ups still open: resource_kind enrichment, Keystone UUID resolution, token-refresh hook for user_id (cloud already live). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7 (Step 11 a/b/c + Codex/cargo-review polish) 자연 게이트에서 일시 정지. Atomic security 결정에 따라 push/PR 없이 stop. - HEAD: 0b63233 (폴리싱 commit) - Tests: 1422 pass single-threaded - Reviews: Codex APPROVED + cargo-review APPROVE (HIGH 0, MED 2 → polished) - Next: Phase 8 Step 12 — Neutron `_filter` IGNORE 패턴 fix - 위치: src/adapter/http/neutron.rs:225/231/240 devflow-state.md (gitignore)는 별도 수동 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix the Neutron list builders that previously ignored their `_filter`
argument, so `:list_*` calls now scope to the active project on the
server side. This restores defense-in-depth alongside the FR2 worker
origin guard (Phase 7) and is a prerequisite for Step 13 response
refilter + AdapterFilterViolation events.
Bug surface
- src/adapter/http/neutron.rs:225/231/240 — `build_network_query`,
`build_security_group_query`, `build_floating_ip_query` discarded
`filter` (`_filter` prefix) and emitted pagination only. Admin tokens
saw all projects regardless of the active scope, contradicting Nova
/ Cinder behaviour. RED-time discovery (2026-04-27).
Policy
- `filter.all_tenants == true` → query contains `all_tenants=1`,
no `tenant_id` (admin opt-out, mutually exclusive).
- `filter.all_tenants == false && filter.tenant_id = Some(scope)`
→ query contains `tenant_id={scope}`.
- `filter.all_tenants == false && filter.tenant_id = None`
→ pagination only (fail-safe — server
keeps its default scoping under the
current admin token).
Filter struct extension (src/port/types.rs)
- `NetworkListFilter`, `SecurityGroupListFilter`, `FloatingIpListFilter`
gain `pub tenant_id: Option<String>` next to the existing
`all_tenants: bool`. `Default` keeps `tenant_id = None`.
Worker enrichment (src/worker.rs)
- `run_worker` snapshots `rbac.project_id()` per dispatch and passes it
into `handle_action` as `active_tenant: Option<String>` via the spawn
closure. `handle_action` derives `scoped_tenant_id = if at { None }
else { active_tenant.clone() }` so the read-side scope matches the
worker mutation guard's view of the world.
- 3 Neutron list call sites (`FetchNetworks`, `FetchSecurityGroups`,
`FetchFloatingIps`) now populate `tenant_id`.
Tests (+4 net = +7 RED − 3 stale)
- New: 7 RED tests in `src/adapter/http/neutron.rs::tests` covering
the policy matrix above (3 builders × {inject, all_tenants_true skips
tenant_id, no-op for security_group}).
- Removed: `test_build_network_query_no_all_tenants_param`,
`test_build_security_group_query_no_all_tenants_param`,
`test_build_floating_ip_query_no_all_tenants_param` — they pinned the
ignored-`_filter` regression as intended behaviour. The new
`*_all_tenants_true_skips_tenant_id` triplet asserts the correct
inversion.
- Subnet listing is unchanged (Q1 matrix decision: `list_subnets`
scopes via the parent network/device).
Verification
- `cargo test --lib -- --test-threads=1`: 1422 → 1426 pass, 0 fail.
- `cargo clippy --all-targets -- -D warnings`: clean.
- Files touched are fmt-clean (pre-existing fmt diffs in app.rs /
action_channel.rs / etc. are leftovers from earlier phases and will
be batched in the Phase 11 final pass).
Atomic security envelope: still no push / no PR until Phase 11 completes
(per state.md A안). Single PR contract intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
audit.md only — Step 12 complete + 5회차 session-end + handoff markers. devflow-state.md is gitignored and updated separately. - phase8-step12-complete: 1426 pass, +4 net (+7 RED − 3 stale), commit fa5efaa, neutron tenant_id injection working as designed - session-end: mid-cycle stop after Step 12 (option A — incremental safety, no push). Atomic security envelope intact. - next-resume: Phase 8 Step 13 — adapter response refilter + HasTenantId trait + AdapterFilterViolation audit event (code-plan.md line 313~). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the cross-project response refilter as a self-contained pure helper. This is the foundation for Step 13b/14, where Neutron (networks / security groups / floating ips) and Nova/Cinder (servers / volumes / snapshots) list endpoints will plumb their responses through `refilter_by_scope` and emit per-item `CrossProjectBlockEvent`s with reason `AdapterFilterViolation` when the server returns rows outside the active project. Why a separate cycle for 13a - Step 13b touches every Neutron list endpoint plus the `NeutronHttpAdapter` constructor (new `audit_logger` field) and every call site in app.rs / main.rs. Splitting the pure helper out keeps that blast radius from contaminating this commit and gives reviewers a focused unit (~140 LoC + 5 tests, no external dependencies) before the wiring lands. API surface (`src/adapter/http/scope_refilter.rs`, new) - `pub trait HasTenantId` — `tenant_id() -> Option<&str>` / `resource_id() -> Option<&str>`. Implementors land in 13b/14 for Network / SecurityGroup / FloatingIp / Server / Volume / Snapshot. - `pub fn refilter_by_scope<T: HasTenantId>(items, active, all_tenants) -> (Vec<T>, Vec<T>)` — partitions into `(kept, dropped)`. Policy - `all_tenants == true` → no-op (admin opt-out). - `all_tenants == false && active.is_none()` → no-op (no ground truth; worker-side guard already blocks mutations via UnscopedFailSafe). - `all_tenants == false && active.is_some()` → strict refilter. Items whose `tenant_id() == active` go to `kept`; everything else — including items with `tenant_id() == None` — goes to `dropped` (fail-safe: cannot prove the row belongs to the active scope). Allocation strategy - `kept = Vec::with_capacity(items.len())` (common path: zero drops). - `dropped = Vec::new()` (lazy — only allocates on first reject). - The doc comment explicitly warns reviewers off `Iterator::partition`, which pre-allocates both sides. Audit hand-off contract (documented for 13b) - `dropped.iter()` produces every row the caller MUST emit an `AdapterFilterViolation` event for, even when the row's `tenant_id() == None` — the audit chain depends on every drop being attributable. Tests (+5, 1426 → 1431) - `test_refilter_drops_cross_project_items_when_scope_strict` - `test_refilter_keeps_all_when_all_tenants_true` - `test_refilter_keeps_active_scope_items` - `test_refilter_drops_items_with_missing_tenant_id_when_strict` (fail-safe — `tenant=None` rejected when active is set) - `test_refilter_no_op_when_active_none` (active=None short-circuits, items pass through untouched) Doc polish from cargo-review (Multi-Agent: Correctness/Style/ Suggestions, verdict APPROVE-WITH-MINOR) - Style MED #2 — `tenant_id` / `resource_id` trait methods now carry their own `///` docs (clippy `missing_docs` parity). - Suggestions IDIOM #1 — fn-level doc warns against `Iterator::partition` regression. - Suggestions SECURITY #6 — module-level doc spells out the None-tenant_id-must-still-emit contract for Step 13b. Deferred to Step 13b decision points (real call sites available) - `RefilterScope { active, all_tenants }` struct vs. positional args (Suggestions READABILITY #2). - `resource_id() -> Option<&str>` vs. `&str` once we know the Network / Server / Volume model id shapes (Suggestions IDIOM #3). - Trait rename `HasTenantId` → `RefilterableItem` / `ScopedItem` (Suggestions READABILITY #4). Verification - `cargo test --lib -- --test-threads=1`: 1426 → 1431 pass. - `cargo clippy --all-targets -- -D warnings`: clean. - New file is fmt-clean; pre-existing fmt diffs elsewhere remain scheduled for the Phase 11 batch pass. Atomic security envelope intact: still no push / no PR until Phase 11 completes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 13b RED only) audit.md only — Step 13a complete + Step 13b RED-only (option C incremental impact measurement) + 6회차 session-end + handoff markers. devflow-state.md is gitignored and updated separately. - phase8-step13a-complete: 1431 pass (+5), commit 021dbe0, scope_refilter pure helper with cargo-review APPROVE-WITH-MINOR (3 doc polishes inlined; 4 suggestions deferred to 13b decision points). - phase8-step13b-RED-only: 5 RED tests added to scope_refilter.rs::tests for HasTenantId impl on Network/SecurityGroup/FloatingIp. RED verified = 10 E0599 compile errors (method tenant_id/resource_id not found). Intentional broken build — restored by 13b GREEN next session. Impact scope measured = 4 files (scope_refilter.rs +impls / neutron.rs +audit_logger field +list_* wiring +emit / registry.rs new_http sig / main.rs caller). NeutronHttpAdapter::from_base call sites = registry.rs:64 only (smaller blast than initial estimate). - session-end: option C — RED only + impact measurement, no push. Atomic security envelope intact. - next-resume: Phase 8 Step 13b GREEN. Step 1: add 3 HasTenantId impls (1431 → 1436). Step 2: NeutronHttpAdapter audit_logger field + list_* refilter wiring + emit. Step 3: registry/main caller updates. Uncommitted on resume: src/adapter/http/scope_refilter.rs (+85 lines RED tests). Run `cargo test --lib --no-run` to confirm intentional RED before adding impls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st models GREEN-only commit that turns the 5 RED tests staged in 6회차 into passing assertions. No new tests, no signature changes — strictly trait-impl-and-restore-build. What - `impl HasTenantId for Network` / `SecurityGroup` / `FloatingIp` in `src/adapter/http/scope_refilter.rs`. All three share the same shape: `tenant_id()` delegates to `self.tenant_id.as_deref()` (`Option<String>` → `Option<&str>`), `resource_id()` returns `Some(&self.id)` (id is always present on the wire). Why now (sub-cycle split) - 13b-2 still has the heavyweight changes ahead — `NeutronHttpAdapter` gains `audit_logger` and `scope_provider` fields, every Neutron list_* impl wires `refilter_by_scope` + per-drop `cross_project_audit::emit`, and 13b-3 propagates the constructor change through `registry::new_http` and `main.rs`. Landing the trait impls first restores a green build (1431 → 1436) and gives the next commit a clean diff focused on the adapter rewrite. Tests (1431 → 1436) - The 5 tests committed in the 6회차 RED-only step now pass: - `test_network_has_tenant_id_returns_some_when_present` - `test_network_has_tenant_id_returns_none_when_absent` - `test_security_group_has_tenant_id_returns_some_when_present` - `test_floating_ip_has_tenant_id_returns_some_when_present` - `test_floating_ip_has_tenant_id_returns_none_when_absent` - `cargo test --lib --no-run` no longer produces 10 E0599s. - `Network`/`SecurityGroup`/`FloatingIp` confirmed (RED-time): every model carries `tenant_id: Option<String>` and `id: String`, so `as_deref()` + `Some(&self.id)` is the correct mapping with no per-type divergence. Verification - `cargo test --lib -- --test-threads=1`: 1431 → 1436 pass. - `cargo clippy --all-targets -- -D warnings`: clean. - File is fmt-clean. Atomic security envelope intact: still no push / no PR until Phase 11 completes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
audit.md only — 7회차 resume + Step 13b-1 complete + 13b-2 design frozen (not implemented this session) + session-end + handoff. devflow-state.md is gitignored and updated separately with the "Step 13b-2 Design (FROZEN)" section. - session-resumed: tests-at-HEAD=1431, RED uncommitted confirmed 10 E0599 as designed. - phase8-step13b-1-complete: HasTenantId for Network / SecurityGroup / FloatingIp, all same shape, 6회차 RED 5 tests green, 1431 → 1436, commit 0e66271. - step13b-2-design-frozen: full design (NeutronAuditCtx struct + emit_filter_violations<T:HasTenantId> + Adapter audit_ctx field + with_audit builder + 3 list_* wiring pattern + 5 RED test plan with tempdir AuditLogger fixture) recorded in state.md so the next session can drop straight into RED. - session-end: user pause before 13b-2 code. Clean tree, no broken build. Atomic security envelope intact. - next-resume: Phase 8 Step 13b-2 RED → GREEN per FROZEN design, then 13b-3 (registry/main caller) as a separate commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t_* refilter
Wire the response-side defense-in-depth on every Neutron list endpoint.
After Step 12 made the server side scope `tenant_id={active}`, this
commit closes the loop on the client: each `list_*` runs the response
through `refilter_by_scope` and emits a per-row
`AdapterFilterViolation` event when the server still leaks a foreign
project's row.
Why a separate context type
- `NeutronAuditCtx` bundles the three `Arc`-shared pieces every Neutron
list call needs (`AuditLogger`, `ScopeProvider`, `ActorContext`)
without bloating the adapter struct or the `NeutronPort` trait
signature. The same shape will be reusable for Nova/Cinder in
Step 14.
API surface (`src/adapter/http/neutron_audit.rs`, new)
- `pub struct NeutronAuditCtx { logger, scope_provider, actor_ctx }`
- `impl NeutronAuditCtx::emit_filter_violations<T: HasTenantId>(
dropped, action_type, resource_kind, correlation_id)`
- No-op on `dropped.is_empty()` (common path: zero violations).
- Reads `actor_ctx` and `scope_provider` *at emit time* so a
cloud/project switch between the list call and the emit is
reflected immediately (parity with Phase 7 polish #2).
- Builds `CrossProjectBlockEvent::new(...)` with reason
`AdapterFilterViolation { resource_id, project_id }` and
`GuardLayer::Fr1Adapter`. `asserted_origin_project_id` stays
`None` — adapters have no origin information, only target rows.
- `emit` is best-effort: mirrors the worker-side path in
`cross_project_audit::emit`, falling back to `tracing::warn!` on
logger failure.
NeutronHttpAdapter wiring
- New `audit_ctx: Option<Arc<NeutronAuditCtx>>` field. `from_base` and
`new` keep the existing call shape (`audit_ctx = None`); `new`
builder `with_audit(ctx)` attaches a context. Step 13b-3 wires this
through `registry::new_http`; until then the adapter behaves as a
pre-13b passthrough.
- Private helper `refilter_response<T: HasTenantId>(resp, all_tenants,
action_type, resource_kind) -> PaginatedResponse<T>`. No-op when
`audit_ctx` is None; otherwise:
1. Reads `active = scope_provider.current_project_id()` live.
2. Runs `refilter_by_scope` against `resp.items`.
3. Calls `emit_filter_violations(&dropped, ...)`.
4. Reassembles `PaginatedResponse { items: kept, next_marker,
has_more }` so caller-visible pagination is preserved.
- `list_networks` / `list_security_groups` / `list_floating_ips` each
funnel their `paginated_list` result through `refilter_response`
with their own `(action_type, resource_kind)` labels. Other Neutron
list endpoints (subnets / ports / agents) stay untouched — their
scoping path is parent-resource-bound and not Step 13's surface.
`correlation_id` is currently `0`. Phase 8 doesn't have a worker
dispatch epoch threaded into the adapter call chain yet — adding
`Option<u64>` to the `NeutronPort` list_* signatures would balloon
the blast and isn't needed for the AdapterFilterViolation use case
(the canonical fingerprint already disambiguates per-row events).
A follow-up cycle can revisit if cumulative review flags it.
Tests (1436 → 1441, +5)
- `test_neutron_audit_ctx_emit_one_event_per_dropped` — 3-row drop
→ 3 audit lines, each carrying its own `resource_id`.
- `test_neutron_audit_ctx_no_emit_when_dropped_empty` — zero rows
→ audit log untouched.
- `test_neutron_audit_ctx_uses_fr1_adapter_layer_in_event` —
`details.guard_layer == "fr1_adapter"`.
- `test_neutron_audit_ctx_uses_adapter_filter_violation_reason` —
`result.failed == "cross_project_block:adapter_filter_violation"`.
- `test_neutron_with_audit_attaches_ctx_default_none` — verifies
`with_audit` is callable with the expected signature.
- All five use the production `AuditLogger` against a `tempfile::
TempDir` (mirrors `cross_project_audit::tests::
test_emit_with_logger_writes_audit_entry`).
Verification
- `cargo test --lib -- --test-threads=1`: 1436 → 1441 pass.
- `cargo clippy --all-targets -- -D warnings`: clean.
Atomic security envelope intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gistry/main
Caller-side closure for Step 13b. Threads the
`Arc<NeutronAuditCtx>` from the App-owned audit logger and the live
`RbacGuard` / `ActorContext` arcs into `AdapterRegistry::new_http`,
which now attaches it to the `NeutronHttpAdapter` via `with_audit`.
End-to-end: a Neutron list response that leaks a foreign project's
row now produces a fingerprinted `AdapterFilterViolation` audit
entry without any code path opting in.
Why the main.rs reordering
- Pre-13b-3: `AdapterRegistry::new_http` was called immediately after
the auth provider was built (before App / audit_logger were
available). 13b-3 needs the audit logger and `actor_ctx` *before*
the registry, so the call moved past `App::from_registry` +
`audit_logger_arc()` + `actor_ctx` construction. `cloud_region`
is captured up-front so the `cloud` borrow can release before
`config` moves into `App::from_registry` (E0505 otherwise).
API change (`src/adapter/registry.rs`)
- `AdapterRegistry::new_http(auth, region, neutron_audit:
Option<Arc<NeutronAuditCtx>>) -> Result<Self, ApiError>` — the
third parameter is `None`-able. When `Some`, the registry runs
`NeutronHttpAdapter::from_base(...).with_audit(ctx)` so the
Neutron adapter inside `Arc<dyn NeutronPort>` already carries
`audit_ctx` from construction (no interior mutability needed,
no post-hoc Arc surgery).
Wiring (`src/main.rs`)
- `audit_logger.clone().map(|logger| Arc::new(NeutronAuditCtx {
logger, scope_provider: rbac.clone() as Arc<dyn ScopeProvider>,
actor_ctx: actor_ctx.clone() }))`
- The `Arc<RbacGuard> -> Arc<dyn ScopeProvider>` coercion (Step 8)
means the same `rbac` instance feeds FR2 worker stamping,
ScopeProvider live-read, and now the adapter refilter — single
source of truth for active project id.
- `actor_ctx` is shared by reference (Arc) so a runtime cloud
switch via `App::handle_event` (BL-P2-074) is observed by the
adapter on the very next list call (no rebind required).
Other registry consumers unchanged
- `AdapterRegistry::new_mock` is `#[cfg(test)]` and does not call
`new_http`, so no test fixture changes are required. Tests that
care about audit emission already construct `NeutronAuditCtx`
directly (Step 13b-2).
Tests (1441 → 1441, no new tests)
- 13b-3 is wiring only; the behavior was test-covered in 13b-2.
Full suite remains green at 1441.
- `cargo build` (binary) compiles — verifies the borrow
reorganization in `main.rs` doesn't regress the existing flow.
Verification
- `cargo test --lib -- --test-threads=1`: 1441 pass, 0 fail.
- `cargo clippy --all-targets -- -D warnings`: clean (covers the
`nexttui` binary, not just the lib).
Phase 8 (Steps 12 + 13a + 13b-1 + 13b-2 + 13b-3) is now feature-
complete on the BL-P2-085 branch. Next steps: Step 14 Nova/Cinder
defense-in-depth refilter, then Phase 9/10/11. Atomic security
envelope intact — still no push / no PR until Phase 11 completes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…back Polish-only commit applying the immediate-action subset of the Phase 8 cumulative cargo-review (Multi-Agent Full, base=9b79a99 → fd2d2e5, 850 lines, verdict APPROVE-WITH-MINOR + Step-14-refactor-precedent). Behavioral changes: zero. Test count unchanged at 1441. Style MED #1 — NeutronAuditCtx field docs - All three `pub` fields of `NeutronAuditCtx` now carry one-line `///` comments. The trait `HasTenantId` already documents its methods; the struct fields lagged behind. Keeps the type self-documenting for Step 14, which will likely add Nova/Cinder mirrors. Style LOW #5 — correlation_id=0 TODO - The `0` placeholder in `NeutronHttpAdapter::refilter_response` now carries a comment explaining (a) why it's safe today (canonical fingerprint already disambiguates per-row events via resource_id), (b) when it should be replaced (worker→adapter epoch propagation, natural slot is the post-Step-14 refactor cycle). Visible to grep and to the next reader who wonders why the value is hardcoded. Style LOW #6 — main.rs path shortening - Add `use nexttui::adapter::http::neutron_audit::NeutronAuditCtx;` and `use nexttui::context::action_channel::ScopeProvider;`. The `audit_logger.clone().map(...)` block in the registry construction now reads on a single screen with no fully-qualified paths. Style LOW #7 — scope_refilter.rs tests imports - Move `use crate::models::neutron::{FloatingIp, Network, SecurityGroup};` to the top of `mod tests` next to `use super::*`. Drops the historical "Step 13b RED: ..." comment block (the impls are merged; the comment described an intermediate state) and keeps a one-paragraph note describing what the tests assert. Correctness LOW #3 / Suggestions READABILITY #4 — resource_id duplicate - The post-construction `event.resource_id = Some(resource_id);` mutation now carries a comment explaining why the adapter path promotes the row id into the canonical fingerprint slot (Step 11b ctor leaves it None by design — worker case can't always know it; adapter always can). No structural change — extending `CrossProjectBlockEvent::new` to accept `Option<String>` for `resource_id` would force every existing worker call site to update and is deferred to the Step-14-precedent refactor cycle alongside the Builder-vs-positional decision. Deferred to Step-14-precedent refactor cycle (cumulative review verdict) - Suggestions DRY #1 / #2 / #8: lift `refilter_response` into `scope_refilter::refilter_and_audit<T, A>`, turn `NeutronAuditCtx` into a generic `AuditCtx { logger, scope_provider, actor_ctx, service }` with three type aliases, and absorb the registry signature growth into `AdapterAuditConfig`. All three become free before duplicating the pattern across Nova/Cinder in Step 14. - Suggestions IDIOM #10 (13a deferred 4 — `RefilterScope` struct, trait rename `HasTenantId` → `ScopedItem`, `resource_id` keep, fixture helper keep): re-evaluated with real call-site evidence in the Step-14-precedent cycle. - Style LOW #2 (cloud/user_id clone count) collapses naturally when emit_filter_violations moves into the generic `refilter_and_audit`. Verification - `cargo test --lib -- --test-threads=1`: 1441 → 1441 (no test changes; behavioral neutrality of polish confirmed). - `cargo clippy --all-targets -- -D warnings`: clean. - Removed temporary `.cargo-review.toml` (only existed to scope the cumulative review). Atomic security envelope intact — still no push / no PR until Phase 11 completes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mplete + polish) audit.md only — 8회차 resume + Step 13b-2 + Step 13b-3 + Phase 8 cumulative cargo-review + polish + session-end + handoff. devflow-state.md is gitignored and updated separately with the "Step-14-precedent-refactor-cycle (FROZEN)" 9-section plan. Highlights from this session - phase8-step13b-2-complete (commit f2e66f3): NeutronAuditCtx + emit_filter_violations + Adapter wiring, 1441 pass. - phase8-step13b-3-complete (commit fd2d2e5): registry/main wired. Phase 8 feature-complete. - phase8-cumulative-cargo-review: Multi-Agent Full, base=9b79a99 → fd2d2e5, 850 lines. Verdict APPROVE-WITH-MINOR-DOC-POLISH + Step-14-precedent-refactor-cycle. - phase8-polish-complete (commit da38cb9): 5 immediate-action items applied (NeutronAuditCtx field docs / correlation_id=0 TODO / main.rs use shortening / scope_refilter tests use re-grouped / event.resource_id duplicate-set comment). 1441 stable, behavioral 변화 0. - session-end: clean baseline, no broken build. Atomic security envelope intact. - next-resume: Step-14-precedent-refactor-cycle (3 sub-cycles per state.md FROZEN section) → Step 14 Nova/Cinder defense-in-depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce `RefilterScope<'a>` with three ctor-validated states (`strict` / `all_tenants` / `unscoped`) plus `from_parts` adapter that normalizes `active=Some + all_tenants=true` to all_tenants. Raw fields private — the ctor surface encodes the invariant the 5+ Step-14 callers will rely on. Change `refilter_by_scope` signature `(items, active, all_tenants)` → `(items, &RefilterScope)`; body switches to `scope.is_all_tenants()` / `scope.active()`. Migrate the 5 existing scope_refilter tests and the single prod call site (`NeutronHttpAdapter::refilter_response`, `src/adapter/http/neutron.rs:73`) via `RefilterScope::from_parts(...)`. 4 new RED→GREEN tests cover each ctor + from_parts normalization. 1441 → 1445 pass, clippy clean. Sub-cycle 1/3 of the Step-14-precedent-refactor-cycle agreed at 8회차 stop (Phase 8 cumulative cargo-review verdict). Next: refactor-2 (AuditCtx generic + AuditEmitter trait + refilter_and_audit free fn). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…filter_and_audit + AuditCtx generic Generalize the audit context so Step-14 (Nova/Cinder) can reuse the Neutron Step-13b plumbing without a per-service struct. scope_refilter.rs - Add `AuditEmitter<T: HasTenantId>` trait — caller-provided sink for per-row `AdapterFilterViolation` events. Generic over `T` so each adapter context handles its native list-item type without erasing `tenant_id` / `resource_id` to a `&dyn HasTenantId`. - Add `refilter_and_audit<T, A>` free fn that colocates partition (`refilter_by_scope`) with the per-row audit emit. Returns only `kept` because the dropped vec is consumed by the audit path. - 3 RED→GREEN tests via a `CountingEmitter` test double: emits_when_dropped_nonempty / skips_when_audit_none / skips_when_dropped_empty. neutron_audit.rs - Rename `NeutronAuditCtx` struct → `AuditCtx` (service-agnostic) and expose `pub type NeutronAuditCtx = AuditCtx;` plus `NovaAuditCtx` / `CinderAuditCtx` placeholders so callers keep service-named imports while the struct itself is shared. - Convert the inherent `pub fn emit_filter_violations<T>` into `impl<T: HasTenantId> AuditEmitter<T> for AuditCtx`. Body unchanged — trait dispatch reproduces the exact same per-row semantics. - Module docstring updated to reflect the generic ctx + Step-14 reuse story; refactor-3 will add the `service` discriminator. neutron.rs - Migrate `NeutronHttpAdapter::refilter_response` from the `refilter_by_scope` + `ctx.emit_filter_violations` 2-step to a single `refilter_and_audit` call with `audit_ctx.as_deref()`. Behavior preserved — when `audit_ctx` is None, `refilter_and_audit`'s `dropped.is_empty() && audit.is_some()` short-circuit reproduces the prior early-return. Behavioral neutrality: 1445 → 1448 pass (+3 new), clippy clean, touched files fmt clean. Sub-cycle 2/3 of the Step-14-precedent-refactor-cycle. Next: refactor-3 (`AdapterAuditConfig` bundle + `registry::new_http` signature + `main.rs::build_audit_config` helper + trait rename `HasTenantId` → `ScopedItem` + `AuditCtx::service` field). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g + build_audit_config + ScopedItem rename
Final sub-cycle of the Step-14-precedent-refactor-cycle. Bundles the
audit wiring into a single registry argument, colocates per-service
tagging in one helper, and renames the refilter trait so its name
matches its contract.
scope_refilter.rs / neutron.rs / neutron_audit.rs
- Rename trait `HasTenantId` → `ScopedItem` across 22 occurrences
(trait def + 4 impls + 4 trait bounds + 2 imports + 7 docstring/
comment refs). The contract is "this row participates in scope
comparison", not "has a `tenant_id` field" — the new name lets Step 14
cover Cinder models that may carry `project_id` instead.
neutron_audit.rs
- Add `service: &'static str` field to `AuditCtx` plus an
`AuditCtx::service()` getter so the discriminator survives field
privatization. Stamped by `build_audit_config` (`"neutron"` /
`"nova"` / `"cinder"`); not on the audit wire today (would bump
fingerprint canonical from v1).
- Add `AdapterAuditConfig { neutron, nova, cinder: Option<Arc<AuditCtx>> }`
with `Default` so mock registries pass `AdapterAuditConfig::default()`.
- Add `build_audit_config(audit_logger, scope_provider, actor_ctx)` —
the canonical constructor. Returns `Default` (all `None`) when
`audit_logger` is `None`; otherwise stamps three `Arc<AuditCtx>` that
share `logger` / `scope_provider` / `actor_ctx` and differ only in
`service`. `scope_provider: Arc<dyn ScopeProvider>` (not
`Arc<RbacGuard>`) so unit tests can mock with `FixedScope`.
- Tests: 3 new RED→GREEN — `default_all_none` /
`returns_none_when_logger_none` /
`returns_three_service_tagged_ctxs_when_logger_some`. Existing
`build_ctx` fixture stamps `service: "neutron"` so the 5 prior
emit_filter_violations tests keep passing.
scope_refilter.rs
- 1 new RED→GREEN — `test_scoped_item_trait_used_for_refilter_signature`
pins the rename behaviorally (compile-fail without `ScopedItem`).
registry.rs
- `new_http` signature: `neutron_audit: Option<Arc<NeutronAuditCtx>>`
→ `audit: AdapterAuditConfig`. Body wires `audit.neutron` into
`NeutronHttpAdapter::with_audit`; `audit.nova` / `audit.cinder` are
documented as Step 14 follow-ups (Nova/Cinder lack `with_audit` until
defense-in-depth refilter lands).
main.rs
- Replace the inline `Arc<NeutronAuditCtx>` construction with a single
`build_audit_config(audit_logger.clone(), rbac.clone() as _,
actor_ctx.clone())` call. Keeps the existing line-101 `ActionSender`
formatting (Phase-11-final-pass scope) untouched.
Behavioral neutrality: 1448 → 1452 pass (+4 new), clippy clean, refactor-3
files fmt clean (pre-existing Phase-11 diffs preserved). Step-14-
precedent-refactor-cycle is feature-complete; next is Step 14
(Nova/Cinder defense-in-depth refilter — `ScopedItem` impls for Server/
Volume/Snapshot, `with_audit` builders on NovaHttpAdapter /
CinderHttpAdapter, list_* wiring through `refilter_and_audit`,
`audit.nova` / `audit.cinder` consumed in `registry::new_http`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…em impls (Server/Volume/Snapshot) Defense-in-depth response-side refilter for Nova `list_servers`, mirroring the Neutron Step 13b plumbing now that the Step-14-precedent refactor generalized `AuditCtx` / `AdapterAuditConfig`. scope_refilter.rs - `impl ScopedItem for Server` (`models::nova`), `Volume` / `VolumeSnapshot` (`models::cinder`). All three reuse the `tenant_id: Option<String>` field — Volume / Snapshot rename the upstream `os-vol-tenant-attr:tenant_id` / `os-extended-snapshot-attributes:project_id` wire fields, so the impl shape stays identical to Neutron's. Adding Volume / Snapshot in this cycle (not 14b) keeps the trait surface in one diff hunk; 14b's Cinder wiring then needs only the adapter-side changes. - 6 new RED→GREEN tests covering `tenant_id` / `resource_id` for each model in present / absent variants. Fixture helpers (`sample_server` / `sample_volume` / `sample_volume_snapshot`) live next to the Neutron equivalents. nova.rs - Add `audit_ctx: Option<Arc<NovaAuditCtx>>` field plus `with_audit` builder. `NovaAuditCtx` is the Step-14-precedent type alias for `AuditCtx`, so `audit.nova` flows through unchanged. - Add `refilter_response<T: ScopedItem>` helper (mirrors Neutron's), calling `refilter_and_audit` with `RefilterScope::from_parts`. The pre-Step-14 behavior is preserved when `audit_ctx` is None — the helper still calls `refilter_and_audit`, but the `audit=None` / `dropped.is_empty()` short-circuit yields the original items. correlation_id=0 (list_* are not bound to a worker dispatch — same Phase-8 cumulative-review trade-off as Neutron). - Wire `list_servers` through `refilter_response(resp, filter.all_tenants, "FetchServers", "server")` so a malicious or buggy upstream that ignores the server-side scope filter still gets rows dropped + per-row `AdapterFilterViolation` audit emits. registry.rs - `new_http` body now consumes `audit.nova` via `nova.with_audit(ctx)`. Documentation updated; `audit.cinder` follow-up flagged for 14b. Behavioral neutrality: 1452 → 1458 pass (+6 new), clippy clean. Touched files fmt clean (pre-existing Phase-11 diffs preserved). Next: Step 14b — Cinder defense-in-depth (CinderHttpAdapter audit_ctx + with_audit + refilter_response<T> + list_volumes + list_snapshots wiring + registry.audit.cinder). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lumes + list_snapshots) Mirror of Step 14a (Nova) for the Cinder adapter. `ScopedItem` impls for `Volume` and `VolumeSnapshot` already landed in 14a so the trait surface stays in a single hunk — 14b only adds adapter-side wiring. cinder.rs - Add `audit_ctx: Option<Arc<CinderAuditCtx>>` field plus `with_audit` builder. `CinderAuditCtx` is the Step-14-precedent type alias for `AuditCtx`, so `audit.cinder` flows through unchanged. - Add `refilter_response<T: ScopedItem>` helper (identical shape to Nova / Neutron); routes through `refilter_and_audit` with `RefilterScope::from_parts`. `audit_ctx=None` short-circuits the same way (no allocation, items returned unchanged). - Wire `list_volumes` through `refilter_response(resp, filter.all_tenants, "FetchVolumes", "volume")` and `list_snapshots` through `refilter_response(resp, filter.all_tenants, "FetchSnapshots", "snapshot")`. Defense-in-depth: a malicious or buggy upstream that ignores the server-side `tenant_id` scope still gets rows dropped + per-row `AdapterFilterViolation` audit emits. - correlation_id=0 inherits the Phase-8 cumulative-review trade-off shared by Neutron / Nova (list_* not bound to a worker dispatch). registry.rs - `new_http` body now consumes `audit.cinder` via `cinder.with_audit(ctx)`. The Step-14 plumbing is now complete on all three list-emitting adapters (Neutron / Nova / Cinder). Behavioral neutrality: 1458 pass (no new tests — wiring inherits behavioral coverage from the Step 13b-2 `AuditCtx::emit_filter_violations` 5-test suite via `pub type CinderAuditCtx = AuditCtx`). clippy clean. Touched files fmt clean (pre-existing Phase-11 diffs preserved). Step 14 is now feature-complete. BL-P2-085 worker (Phase 7) + adapter (Phase 8 + Step 14) defense-in-depth chains are both wired. Next phases per the A안 Atomic Security Fix plan: Phase 9 (form layer) → Phase 10 (UI) → Phase 11 (bg / final fmt pass) → single atomic PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-string guard + docs + rename comment Apply the 5 immediately-actionable items from the cargo-review report (2 SECURITY + 3 Style polish) before continuing to Phase 9. SECURITY (Sugg #1 + #2) - `RefilterScope::strict("")` would silently drop every row, since no list model carries `tenant_id == ""`. Add `debug_assert!` to catch the caller bug in dev builds. Release builds rely on `from_parts` normalization (next item) — the sole production caller path. - `RefilterScope::from_parts(Some(""), false)` previously routed straight into `strict("")`. Fail-safe normalize: empty string is treated as `None` (unscoped), so a `scope_provider` returning `Some("")` (partial token, edge session) results in the worker-side guard handling mutations rather than the adapter panicking. - 2 new RED→GREEN tests: `_strict_panics_on_empty_active` (`#[should_panic]`) + `_from_parts_treats_empty_string_as_unscoped`. Style polish - Style #1: doc-comment for `RefilterScope::active()` and `is_all_tenants()` (getter contract was implicit). - Style #2: doc-comment for `AuditEmitter::emit_filter_violations` trait method — contract was previously only in the impl on `AuditCtx`, leaving the trait def silent. - Style #3: fix self-referential rename comment `ScopedItem → ScopedItem` (replace_all artifact from refactor-3) back to the intended `HasTenantId → ScopedItem`. Behavioral neutrality on existing call sites: all production callers (`{neutron,nova,cinder}.rs::refilter_response`) use `from_parts`, which now normalizes empty strings before they reach `strict`. Existing tests use non-empty literals (`"A"` etc.) and stay green. 1458 → 1460 pass, clippy clean, refactor-3 files fmt clean (pre-existing Phase-11 diffs preserved). Deferred items from the cargo-review report (3-way `refilter_response` consolidation, `service: &str` → enum, ScopedItem impl macro, AdapterAuditConfig enum invariant, race-window doc) tracked for the post-Phase-11 polish cycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…idator helper, FR4)
Introduce `src/module/common/scope_validator.rs` — the FR4 pre-dispatch
form-scope check that complements FR1 (adapter refilter) and FR2
(worker origin guard).
Module layout
- New `src/module/common/` package with `pub mod scope_validator;`.
Registered in `src/module/mod.rs` between `aggregate` and
`compute_service` (alphabetical).
Surface
- `FormSelection<'a> { field, project_id }` — one row of form input
carrying its scope label. `project_id: Option<&str>` so missing-scope
rows can fail-safe-deny without `&str` empty-sentinel ambiguity.
- `FormValidationError { field: String, reason: CrossProjectReason }`
— first offending field plus the canonical
`CrossProjectReason::FormSelectionMismatch` so the audit emit path
(Phase 10 toast / future audit log entry) shares one schema with FR1
and FR2.
- `pub fn validate_form_scope(active: &str, selections: &[FormSelection])
-> Result<(), FormValidationError>` — iterate, return first mismatch.
`project_id == None` encodes as `selected = ""` for the schema-stable
audit string.
RED → GREEN
- 4 tests cover the four shapes the plan calls out:
`_single_match_passes` / `_single_mismatch_returns_error` /
`_multi_first_mismatch_wins` / `_validation_error_carries_field_name_and_reason`
(`project_id: None` fail-safe). Verified RED with a bodyless
`Ok(())` stub (3 fail, 1 spurious pass on the match case), then
swapped in the real iterator body for full green.
Behavioral neutrality on existing call sites: none yet — Step 16
(Glance pre-mutation Delete/Update wiring) is the first consumer.
1460 → 1464 pass (+4 new), clippy clean, fmt clean on new files
(pre-existing Phase-11 diffs preserved).
Next: Step 16 — Glance Delete/Update pre-mutation GET → owner extract
→ `validate_form_scope` → `Fr4Form` audit emit + reject + toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…4 guard (worker.rs)
Pre-mutation owner check for `Action::DeleteImage` to prevent a stale
form-selected image from being deleted when its owner project no
longer matches the active scope (e.g. user switched clouds/projects
between list refresh and confirm).
Plan adjustment vs code-plan.md Step 16
- Plan placed the hook inside `src/module/image/mod.rs`, but
`ImageModule` doesn't carry an adapter handle — every mutation
flows through `ActionSender::send` → worker. The natural FR4 hook
is therefore worker-side, where the GlanceAdapter is already in
scope along with `audit_logger` and `actor_ctx` (Step 11b
plumbing). Also descoped `UpdateImage` to a follow-up BL because
no `Action::UpdateImage` variant exists today.
worker.rs surface (new helpers, mirror of FR2 Step 11b shape)
- `check_image_owner_scope(image: &Image, active: &str) -> GuardDecision`
— pure decision. `owner == active` → Allow; `owner != active` →
Block(FormSelectionMismatch{selected=owner, active}); `owner == None`
→ Block(FormSelectionMismatch{selected="", active}) — fail-safe
deny when Glance omits the field.
- `emit_form_block_audit(reason, action_type, resource_kind,
resource_id, active_project_id, audit_logger, actor_ctx,
correlation_id)` — `GuardLayer::Fr4Form` mirror of
`emit_origin_block_audit`. Stamps `resource_id` on the top-level
event so the v1 fingerprint stays unique per row; passes
`asserted_origin_project_id = None` (form layer has no origin to
compare). correlation_id=0 (form not bound to a dispatch epoch);
the future epoch-propagation cycle gets a real value.
worker.rs integration
- `handle_action` gains `audit_logger: Option<&AuditLogger>` and
`actor_ctx: &Arc<RwLock<ActorContext>>`. `run_worker` clones both
into the per-dispatch task closure.
- `Action::DeleteImage` branch: when `active_tenant` is `Some` and the
pre-mutation GET succeeds, run `check_image_owner_scope`. On Block,
emit the FR4 audit entry, send
`AppEvent::CrossProjectBlocked { reason, action: "DeleteImage" }`,
and return — never calling `delete_image`. Pre-GET failure or
unscoped session falls through to the existing delete path so the
user sees the underlying ApiError, not a silent block.
Tests (5 new RED → GREEN)
- `_check_image_owner_scope_match_allows`
- `_check_image_owner_scope_mismatch_blocks`
- `_check_image_owner_scope_missing_owner_fail_safe`
- `_emit_form_block_audit_writes_entry_with_fr4_form_layer`
(TempDir + AuditLogger, asserts
`details.guard_layer == "fr4_form"`,
`result.failed == "cross_project_block:form_selection_mismatch"`,
`resource_id == "img-99"`, `correlation_id == 17`)
- `_emit_form_block_audit_does_not_panic_when_logger_none`
Pure helpers cover the decision matrix; `handle_action` integration is
not unit-tested directly because `MockGlanceAdapter::get_image` returns
`NotFound` — would need a configurable mock to drive the cross-project
path through `handle_action`. Tracked as follow-up: `MockGlanceWithImage`
or behavior-override mock.
1464 → 1469 pass (+5 new), clippy clean, worker.rs fmt clean
(pre-existing Phase-11 diffs preserved).
Next: Phase 10 Step 17 — `src/ui/cross_project_toast.rs` (3 builders +
safe_display 60-char + ToastLevel::Warning).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BL-P2-089 (Medium): Glance `UpdateImage` Action variant + FR4 guard. Step 16 descoped UpdateImage because `Action::UpdateImage` doesn't exist today; this BL adds the variant + worker handler + same FR4 pre-mutation GET pattern as DeleteImage. - BL-P2-090 (Low): `MockGlanceWithImage` configurable mock for the `handle_action(Action::DeleteImage)` integration test gap left by Step 16 (pure helpers cover the matrix, but the worker branch itself isn't unit-tested because the default mock returns NotFound). IDs chosen 089/090 because memory tracks 087/088 as already pre-allocated by main's BL-P2-086 follow-ups (cleanup / pre-flight). Reconcile if main backlog.md diverges at PR-merge time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…facing builders) User-visible toast builders for the three cross-project block scenarios. Lives in `src/ui/cross_project_toast.rs`, registered via `src/ui/mod.rs`. Surface - `origin_mismatch_toast(origin, target) -> (String, ToastLevel::Error)` — FR2 worker block (dispatch origin ≠ active scope). - `glance_owner_mismatch_toast(owner, active) -> (..., Error)` — FR4 pre-mutation block specifically for Glance images (Step 16 partner). - `form_mismatch_toast(field, selected, active) -> (..., Error)` — FR4 generic form-selection block carrying the offending field label. - Internal `safe_display_60` char-count truncate (`…` suffix) so a pathological project/image/field name can't overrun the one-line toast region. Char-based for now; width-aware swap via `unicode-width` will land with BL-P2-050. Plan adjustments vs code-plan.md Step 17 - Plan called for `ToastLevel::Warning`, but the enum has no Warning variant — Phase 7 Step 11c already routes `AppEvent::CrossProjectBlocked` through Error TTL (PermissionDenied parity). Aligned the test (`_is_error` not `_is_warning`). - `safe_display` is tracked under BL-P2-050 (BL-P2-074 deferred it); introducing it project-wide is out of scope. Inlined the 60-char truncate here with the same name so BL-P2-050 can replace the body without touching call sites. Tests (5 RED → GREEN, single-pass single-file) - `_origin_mismatch_toast_contains_both_project_names` - `_glance_owner_mismatch_toast_contains_owner_name` - `_form_mismatch_toast_contains_field_label` - `_toast_level_is_error` (3 builders all Error) - `_toast_respects_60char_truncate` (100-char input → 59 chars + `…`) 1469 → 1474 pass (+5 new), clippy clean. Next: Step 18 (Phase 11) — background poll architectural pin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… pin Inspection of `poll_server_status` / `poll_migration_progress` shows both pollers send `AppEvent`s directly on `event_tx` and never dispatch through `ActionSender`. The plan-time worry that background tasks might emit mutation actions with a stale scope therefore does not apply — the FR2 origin-stamping path covers only the user-initiated mutation dispatch loop. Doc additions (worker.rs) - `poll_server_status` doc: declares the read-only contract and tells future maintainers to route any new mutation dispatch through `ActionSender::send` so the Step-9 central stamping handles origin automatically. Hand-built `DispatchedAction`s in poll bodies are forbidden. - `poll_migration_progress` doc: same pin, cross-references the sibling. - Module-level test-section note inside `worker::tests` summarizes why no runtime/signature-pin test was added: the pollers are multi-minute async loops not suited to unit invocation without elaborate mocking, and `async fn` doesn't coerce to a `fn` pointer so a compile-time signature guard would be artificial. The two function doc comments are the canonical guard. Plan adjustments vs code-plan.md Step 18 - Plan listed two RED tests (`_emits_action_with_current_scope_stamp` / `_stale_action_blocked_after_scope_switch`) under the assumption that pollers call `ActionSender::send`. Re-reading the code shows they don't — those tests would assert a behavior that isn't even surfaced by the current implementation. Switched to doc-only arch pinning so a future refactor that wires `ActionSender` into the poll path automatically inherits the FR2 stamping covered by the existing 1474 tests. No code-path changes — purely architectural documentation. 1474 pass, clippy clean. **Phase 11 feature-complete.** BL-P2-085 worker (FR2) + adapter (FR1) + form (FR4) + UI (FR6) + background pin (FR2 scope) chains are all wired. Ready for the PR-direct finishing pass: external review, Phase-11-final-pass fmt, main sync, atomic security PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backlog hook auto-appended a file-edit line at the end of audit.md after the BL-P2-089/090 registration commit (a2bce46). Splitting it out as a separate chore so the Step 17/18 commits stay scoped to their actual surface (toast / worker doc). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s + DeleteImage docstring Apply the 3 immediately-actionable items from the branch-full cargo-review verdict (Correctness #2/#3/#5) before the PR-direct finishing pass. Correctness #3 — `module/common/scope_validator.rs` - `validate_form_scope` now `debug_assert!(!active.is_empty())` on entry. Parity with `RefilterScope::strict` (cargo-review S-class follow-up fd60d83). An empty active combined with selections carrying `project_id == Some("")` would silent-allow cross-project rows; production callers must short-circuit to the unscoped path (worker FR2) before reaching this validator. Correctness #5 — `worker.rs::check_image_owner_scope` - Same `debug_assert!(!active.is_empty())` parity. The current production caller (`Action::DeleteImage` branch) already wraps the call in `active_tenant.as_deref()`, so empty active can only arrive if the token starts returning `project_id == Some("")`. Caught in dev builds; release builds rely on the upstream `from_parts`-style normalization that the worker dispatch path already applies. Correctness #2 — `worker.rs::Action::DeleteImage` docstring - Previous comment said "FR2 already handles the unscoped failsafe", which is inaccurate. Unstamped envelopes route through `check_dispatched_origin` → Allow (origin=None), and FR4 also skips because `active_tenant` is None. RBAC `can_perform` is the only gate; pre-auth routing is the App layer's responsibility. - Also documented the pre-GET failure trade-off explicitly: 404 OK pass-through is intentional, but 401/403/5xx could route past FR4 in principle. A future BL can fail-closed for auth/5xx while keeping 404 semantics. Tests (2 new RED → GREEN, `#[should_panic]`) - `_validate_form_scope_panics_on_empty_active` - `_check_image_owner_scope_panics_on_empty_active` 1474 → 1476 pass (+2 new), clippy clean. Next (Phase B): register BL-P2-091/092/093/094 follow-ups from the cargo-review report — Glance FR1, cross-resource pre-mutation FR4, actor_ctx.user_id cloud-switch sync, fingerprint v2 schema. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ew branch-full follow-ups cargo-review branch-full (39 commits, base=main) verdict was NEEDS DISCUSSION because 3 SECURITY + 2 MED findings touch the atomic security promise itself. Phase A (b40f3af) reflected the 3 immediate fixes; this commit registers the remaining 4 as follow-up BLs so the PR body can reference concrete IDs. - BL-P2-091 (Medium): Glance FR1 — `ScopedItem for Image` + `GlanceHttpAdapter::with_audit`. Closes the asymmetry that `FetchImages` currently leaks cross-project rows into the list UI while only `DeleteImage` is FR4-protected. - BL-P2-092 (Medium): Cross-resource pre-mutation FR4 — AssociateFloatingIp / AttachVolume / DetachVolume etc., where the two resources can live in different projects. Adds `check_pair_scope` helper. - BL-P2-093 (High): `actor_ctx.user_id` cloud-switch live sync. Current wire-startup fixation breaks audit attribution AND fingerprint v1 dedup across multi-cloud switches. - BL-P2-094 (Low, v2-trigger): Fingerprint v2 schema with `|` escape or length-prefix. v1 is LOCKED but the unescaped delimiter can collide on customizable fields; v2 bump must include the fix. PR body will name BL-P2-091/092/093/094 as deferred but documented findings; BL-P2-093 (High) flagged for fast-follow after the BL-P2-085 PR merges. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng fmt diffs Apply `cargo fmt` across the worktree to clear the pre-existing fmt diffs that earlier sub-cycles deferred to this single Phase-11 pass. Every prior commit message that mentioned "touched files fmt clean (pre-existing Phase-11 diffs preserved)" was banking on this commit. Files affected (line-collapses / chain reformats / arg-list joins — no semantic change): - src/app.rs (`set_actor_ctx` arg list) - src/context/action_channel.rs (4 spots — `recv` / `into_inner` / `send` / `try_channel` tuple) - src/error.rs (assert! macro wrapping) - src/infra/cross_project_audit.rs (assert chains) - src/infra/rbac.rs (`scope_decision` arg list) - src/main.rs (`ActionSender::new` call site) - src/adapter/http/scope_refilter.rs (test fn arg-list) - src/ui/cross_project_toast.rs (test bodies) - src/worker.rs (assorted) 1476 pass (no count change — behavioral neutrality), clippy clean, `cargo fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ss-project-scoping
…FetchPortBindingsForServer` (BL-P2-086)
BL-P2-086 merged `Action::FetchPortBindingsForServer { server_id }` for
the live-migrate stale-port-binding diagnosis (PR #83). Phase 5 Step 7
of BL-P2-085 removed the `_ => None` catch-all from `action_to_kind`
on purpose so every new Action variant has to be classified explicitly
— this is the trip-wire that caught the merge.
Classification: read-only Fetch* (no RBAC kind, no FR2 stamp). Lands
in the same arm as `Action::FetchPorts { .. }` / `FetchMigrationProgress`
/ etc. — all the other server-detail polling fetches.
1476 → 1492 pass (+16 from BL-P2-086 test suite), clippy clean,
`cargo fmt --check` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 12, 2026
bluejayA
added a commit
that referenced
this pull request
May 13, 2026
… refilter (#87) BL-P2-085 follow-up. Closes the FR1+FR2+FR3+FR4 layered defense asymmetry that atomic security PR #85 deferred for Glance image-visibility semantics. `ScopedItem` gains an `is_globally_accessible()` predicate (default false preserves Neutron/Nova/Cinder behavior). `Image` overrides to return true for `public`/`community`/`shared` visibility (positive allowlist; unknown values fail-safe to owner refilter). `GlanceHttpAdapter::with_audit` + `list_images` refilter wiring + `registry::new_http` consumes `audit.glance`. Codex P1 fix included: visibility-aware short-circuit prevents non-admin users losing access to public Ubuntu/distro images. FR1 leak signal preserved: `visibility=private` AND `owner != active`. cargo test = 1504 passed (1494 → +10). clippy clean. fmt clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BL-P2-085: Cross-project Scoping — Atomic Security Fix
Summary / 요약
f66b7de(post-BL-P2-086 main HEAD)Fr1Adapter/Fr2Worker/Fr3Rbac/Fr4FormCrossProjectGuard+DispatchedAction+AppError::CrossProjectBlockedinfra/cross_project_guard.rs,action.rs,error.rsCrossProjectBlockEvent+ fingerprint v1 +AuditLoggerintegrationinfra/cross_project_audit.rscheck_project_scope+RbacScopeDecision+ exhaustiveaction_to_kindinfra/rbac.rs,worker.rsScopeProvidertrait +ActionSendercentral stamping (Step 9)context/action_channel.rsworker.rs,app.rsadapter/http/neutron.rs,neutron_audit.rs,scope_refilter.rsRefilterScope/AuditEmitter/refilter_and_audit/AdapterAuditConfig/build_audit_config/ScopedItemrenamescope_refilter.rs,neutron_audit.rs,registry.rsadapter/http/nova.rs,cinder.rsFormSelectedIdValidator+ Glance DeleteImage pre-mutation GETmodule/common/scope_validator.rs,worker.rsui/cross_project_toast.rsworker.rsDesign decisions / 설계 결정 사항
"v1\|user\|active\|origin\|target\|action\|resource_id"→ sha256[..6]. 새 field 추가는 v2 bump 필수 (BL-P2-094). / v2 schema bump (BL-P2-094) is required to add fields; the v1 canonical is frozen.correlation_id=0for adapter list_* — list 호출은 worker dispatch에 묶이지 않아 epoch 부재. fingerprint가resource_id로 dedup 보장. epoch propagation은 별 cycle. / List calls aren't tied to a worker dispatch epoch; per-row uniqueness is guaranteed byresource_idin the fingerprint.DeleteImage만 FR4(pre-mutation owner GET)로 보호.FetchImageslist refilter는 follow-up BL-P2-091 (image visibility 의미론 차이 — public/private/shared/community). / Glance image visibility semantics make a simpleowner==activerefilter ambiguous; tracked separately.actor_ctx.user_idwire-startup 고정 —cloud필드만ContextChanged에서 live update. multi-cloud 환경에서 user 변경 audit attribution은 BL-P2-093 (High). /user_idis captured once at startup; multi-cloud user-switch attribution is tracked as a High-priority follow-up.AssociateFloatingIp/AttachVolume등 두 resource project 비교는 BL-P2-092. OpenStack 서버측 RBAC에 위임 중. / Cross-resource project mismatch currently relies on OpenStack-side RBAC; BL-P2-092 adds a client-side guard.RefilterScope::from_partsempty-string normalization + 3-layer empty-active debug_asserts (cargo-review S-class follow-ups, commitsfd60d83+b40f3af).Some("")→ unscoped (fail-safe),strict("")→ panic in dev. / Empty-active inputs are normalized to unscoped silently; dev builds panic to catch caller bugs.get_imageErr → 기존 delete 흐름 (api_error로 사용자에 표면). 401/403/5xx fail-closed는 follow-up. / GET failures fall through to the existing delete path so the user sees the underlying error; auth/5xx fail-closed handling is a follow-up.poll_server_status/poll_migration_progress는event_tx만 사용,ActionSender::send호출 X. doc-only architectural pin. / Background pollers are read-only by construction; they emitAppEventdirectly and never dispatch mutation actions.RbacState::scope_decisionatomicity (Codex P1 hotfixb4b4c44) —(role × scope)결정이 단일 RwLock read 안에서. role/scope race 차단. / The(role, scope)decision is materialized inside a single RwLock read to prevent race conditions between role and project-id snapshots.Test plan
cargo test --lib -- --test-threads=1— 1492 passed / 0 failedcargo clippy --lib --bins --tests -- -D warnings— cleancargo fmt --check— cleanFollow-ups registered
UpdateImageAction variant + FR4 가드MockGlanceWithImageconfigurable mock forhandle_actionFR4 integration testScopedItem for Image+with_auditactor_ctx.user_idcloud-switch live sync (multi-cloud audit attribution)|escape + length-prefix)🤖 Generated with Claude Code