diff --git a/Cargo.lock b/Cargo.lock index 362c279..cd0fea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1246,6 +1246,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 17134eb..7237532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ uuid = { version = "1", features = ["v4"] } async-trait = "0.1" reqwest = { version = "0.12", features = ["json"] } chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" diff --git a/devflow-docs/audit.md b/devflow-docs/audit.md index 0b39376..fb3a286 100644 --- a/devflow-docs/audit.md +++ b/devflow-docs/audit.md @@ -281,3 +281,154 @@ - 2026-04-23T02:23:45Z — file-edit — devflow-docs/backlog.md [2026-04-23T03:36:39Z] pr-merged | bl=BL-P2-080 | pr=80 | squash-sha=733d88f | state=archived [2026-04-23T03:36:39Z] flow-finished | bl=BL-P2-080 | mode=A-local-merged-worktree-removed + +[2026-04-24T01:34:48Z] new-flow | mode=clean-start | previous-inception=bl-p2-074 | archived-to=.archive/inception-20260424T102911,.archive/construction-20260424T102911 | workspace-preserved=yes +[2026-04-24T01:34:48Z] new-session-started | user-intent="BL-P2-085 정식 cycle: Cross-project scoping 전면 fix" +- 2026-04-24T01:36:58Z — file-edit — devflow-docs/inception/workspace.md +[2026-04-24T01:37:47Z] stage-complete | stage=workspace-detection | gate=C | note="브라운필드 유지, delta update. 경로 정정 (src/adapter/openstack→src/adapter/http, rbac→src/infra/rbac.rs)" | commit=c4590ab +[2026-04-24T01:38:39Z] stage-complete | stage=complexity-declaration | value=Standard | reason="4축 변경(adapter/worker/rbac/test) 얽혔지만 아키텍처 재설계 없음" +- 2026-04-24T02:07:00Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T02:17:33Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T04:02:04Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T04:46:54Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T06:29:40Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T06:30:23Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T06:32:45Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T06:33:19Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T06:34:52Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T06:40:00Z — file-edit — devflow-docs/inception/requirements.md +[2026-04-24T06:40:35Z] stage-artifact-saved | stage=requirements-analysis | path=devflow-docs/inception/requirements.md +[2026-04-24T06:40:35Z] stage-review | stage=requirements-analysis | reviewer=aidlc:spec-reviewer | verdict=Approve-with-notes | must-fix-addressed=3 | should-consider-addressed=5 +[2026-04-24T07:05:51Z] stage-complete | stage=requirements-analysis | gate=B | open-questions=4(deferred) | assumptions-flagged=3 +[2026-04-24T07:10:03Z] stage-complete | stage=pre-planning | gate=C | note="Bug-fix BL, User Stories/NFR 추가 심화 불필요" +- 2026-04-24T07:48:08Z — file-edit — devflow-docs/inception/workflow-plan.md +- 2026-04-24T07:51:22Z — file-edit — devflow-docs/inception/workflow-plan.md +[2026-04-24T07:51:24Z] stage-complete | stage=workflow-planning-approach | selected=A | reason="P0 atomic security fix, 5 FR 상호 의존" +[2026-04-24T07:52:11Z] branch-name-confirmed | branch=feature/bl-p2-085-cross-project-scoping | env=worktree +- 2026-04-24T08:19:37Z — file-edit — devflow-docs/inception/application-design.md +[2026-04-24T08:19:47Z] worktree-sync | action=rsync-main-to-worktree+restore-main-clean | cwd=worktree +[2026-04-24T08:19:47Z] assumption-check | A1=verified(TokenScope/ScopedAuthSession/RbacGuard.project_id) | A2=negative(no-mock-http-crate) | A3=verified(pure-fn) +[2026-04-24T08:19:47Z] stage-artifact-saved | stage=application-design | mode=LIST | path=devflow-docs/inception/application-design.md | components=9(4-new-5-extended) +[2026-04-24T08:30:04Z] decision | topic=A2-mitigation | choice=pure-fn-extraction | reason="dev-dep 도입은 BL 스코프 외출, 보안 fix 집중, 기존 serde body 패턴과 일관" +[2026-04-24T08:30:04Z] stage-complete | stage=application-design-list | gate=approve | next=DETAIL +- 2026-04-24T08:30:12Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T08:53:49Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T08:54:09Z — file-edit — devflow-docs/inception/requirements.md +- 2026-04-24T09:01:21Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:11:34Z — file-edit — devflow-docs/inception/application-design.md +[2026-04-24T13:12:17Z] stage-artifact-updated | stage=application-design | mode=DETAIL | fr2-semantic=stamped-origin-2b | q1-resolved=endpoint-matrix | q3-resolved=AppError-extension +- 2026-04-24T13:32:40Z — file-edit — devflow-docs/inception/design-review-raw/synthesis.md +[2026-04-24T13:32:50Z] council-review-complete | mode=full | members=codex,gemini | chairman=claude | codex=Approve-with-major-revisions | gemini=Approve-with-notes | synthesis-path=devflow-docs/inception/design-review-raw/synthesis.md | must-fix=7 | should-consider=7 | future-bl=5 +- 2026-04-24T13:34:54Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:35:18Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:36:12Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:37:57Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:39:41Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:40:25Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:42:02Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:43:02Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:43:56Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:44:17Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:45:53Z — file-edit — devflow-docs/inception/application-design.md +- 2026-04-24T13:46:54Z — file-edit — devflow-docs/inception/application-design.md +[2026-04-24T13:48:38Z] stage-artifact-revised | stage=application-design | mode=COUNCIL-REVISION | must-fix-addressed=8 | should-consider-addressed=5 | changed: C1 is_mutation 폐기→action_to_kind 재사용 / C2 StampedAction 폐기→DispatchedAction+ActionSender / C4-bis 신규 / C6 response refilter / C8 Glance pre-check / C9 canonicalization+schema / C10 Warning level +[2026-04-24T13:52:55Z] stage-complete | stage=application-design-detail | gate=B | verdict=approved-after-council-revision | commit=c4590ab +[2026-04-24T13:52:55Z] gate-skip | gate=held-revisit | reason=no-held-items +[2026-04-24T13:52:55Z] inception-phase-review | status=complete +[2026-04-24T13:55:44Z] phase-transition | from=INCEPTION | to=CONSTRUCTION | commit=c4590ab +- 2026-04-24T14:02:45Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-24T14:04:28Z] stage-artifact-saved | stage=code-generation | mode=PLAN | unit=bl-p2-085 | path=devflow-docs/construction/bl-p2-085/code-plan.md | steps=17 | new-files=6 | modify-files=14 | expected-tests=~60 +- 2026-04-24T14:06:33Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:07:08Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:07:32Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:07:52Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:08:41Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:08:57Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:09:20Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:09:43Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-24T14:10:22Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-24T14:10:45Z] plan-review-complete | reviewer=aidlc:spec-reviewer | verdict=Approve-with-notes | must-fix=4 | should-consider=5 | addressed: Step-11 guard_layer+correlation_id / Step-13 adapter_filter event / Step-16 adapter surface verified / Step-18 background-task audit added / Step-7 RBAC parity / Step-9 worker raw mpsc / Step-14 HasTenantId for Server-Volume-Snapshot / e2e defer justified +[2026-04-27T00:56:09Z] stage-complete | stage=code-generation-plan | gate=B | next=GENERATE +[2026-04-27T00:57:59Z] discovery | existing-infra | AuditLogger=src/infra/audit.rs(331-LoC,actively-used,rotation+masking) | CrossTenantGuard=src/infra/cross_tenant.rs(175-LoC,UNUSED-dead-code,break-glass-mode) | impact: Step 4 audit subdir 폐기→ src/infra/cross_project_audit.rs 단일파일 + AuditLogger 재사용. CrossTenantGuard는 이번 BL 무시 (semantically misaligned, 후속 BL에서 정리) +- 2026-04-27T00:58:10Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-27T01:02:03Z] tdd-step-complete | step=1 | unit=cross_project_guard | tests-added=9 | total=1379 | regression=0 +[2026-04-27T01:02:03Z] tdd-step-complete | step=2 | unit=dispatched_action | tests-added=2 | total=1381 | regression=0 +- 2026-04-27T01:02:05Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:02:05Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:08:29Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:09:13Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:10:01Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:11:05Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:40:20Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:41:00Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T01:42:02Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-27T01:42:47Z] plan-revised | trigger=user-deeper-impact-check | sections=Step-4-AuditLogger-integration,Step-11-AuditLogger-injection,Step-12-Neutron-filter-IGNORE-fix,Step-13-refilter-skip-when-all-tenants,Step-14-Nova-Cinder-defense-in-depth-only,Policy-Clarification-section-added | discoveries: AuditLogger-actively-used,all_tenants-Arc-AtomicBool-widespread,Neutron-_filter-IGNORE-bug-confirmed,Nova-Cinder-already-correct,CrossTenantGuard-truly-dead +[2026-04-27T01:48:12Z] session-pause | reason=user-requested | phase=CONSTRUCTION | stage=code-generation | last-completed=Step-2-DispatchedAction | next-resume=Step-3-AppError-CrossProjectBlocked | tests=1381 | regression=0 | sot=devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T02:08:05Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T03:20:38Z — file-edit — devflow-docs/construction/bl-p2-085/review-phase1-2-codex.md +- 2026-04-27T07:26:46Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T07:26:47Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T08:18:17Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T08:18:51Z — file-edit — devflow-docs/construction/bl-p2-085/review-step4-codex.md +- 2026-04-27T08:25:28Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-27T08:50:08Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md + +[2026-04-27T11:30:00Z] session-end | reason=user-requested | phase=CONSTRUCTION | stage=code-generation | last-completed=Step-7-action_to_kind-exhaustive | next-resume=Phase-6-Step-8-ScopeProvider-trait | tests=1406 | regression=0 | commits-on-branch=5 (ca2ec2a/e68d50b/53d7292/626cd64/482af90) | uncommitted=0 | sot=devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-27T11:30:00Z] cross-session-handoff | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) verify HEAD == 482af90 (3) read project_nexttui.md memory for full context (4) optional: /codex:review --scope branch --base 53d7292 to validate Phase 4+5 before entering Phase 6 (5) Phase 6 Step 8 = ScopeProvider trait in src/context/action_channel.rs — small, but Step 9-10 are signature-breaking and require user confirmation +[2026-04-27T11:30:00Z] flake-tracking | test=adapter::auth::keystone_project_directory::tests::max_pages_cap_trips_error | source=BL-P2-080 | freq=~1/8 runs | mitigation=re-run | recommendation=신규 BL-P2-086으로 등록하여 mock 격리 또는 #[serial] attr 도입 +[2026-04-27T23:09:28Z] memory-sync-staleness-skipped | branch=feature/bl-p2-085-cross-project-scoping | ahead=0 | reason=upstream-unset +[2026-04-27T23:12:07Z] devflow-state-resync | branch=feature/bl-p2-085-cross-project-scoping | reason=stale-Phase1-only → Phase5-done-Phase6-Step8-next | source-of-truth=code-plan.md+memory +[2026-04-28T01:20:59Z] codex-review-run | scope=branch | base=53d7292 | verdict=needs-changes | findings=1 P1 (rbac.rs:191-194 race) | review-saved=~/projects/docs/reviews/2026-04-28-bl-p2-085-phase4-5-rbac-action-feat-bl-p2-085-codex.md +[2026-04-28T01:20:59Z] codex-p1-hotfix-applied | commit=b4b4c44 | tests=1407 (+1) | clippy=clean | followup-to=626cd64 +[2026-04-28T01:26:27Z] codex-review-rerun | scope=branch | base=53d7292 | verdict=approved | findings=0 | note=P1 fix verified clean | review-saved=~/projects/docs/reviews/2026-04-28-bl-p2-085-phase4-5-post-p1-fix-feat-bl-p2-085-codex.md +- 2026-04-28T05:55:47Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-28T01:35:00Z] phase6-step8-complete | commit=73b347d | tests=1410 (+3 ScopeProvider) | clippy=clean | next=Step-9-ActionSender-signature-replace +[2026-04-28T01:36:00Z] session-end | reason=user-requested-stop-after-clean-baseline | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-6-Step-8-ScopeProvider-trait | next-resume=Phase-6-Step-9-ActionSender-signature-replace+stamping | tests=1410 | regression=0 | commits-on-branch=8 (ca2ec2a/e68d50b/53d7292/626cd64/482af90/df646fd/b4b4c44/26ac5dc/73b347d) | uncommitted=audit.md (this commit) | sot=devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-28T01:36:00Z] cross-session-handoff | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) verify HEAD == this-commit (3) cargo test --lib → 1410 pass (4) read project_nexttui.md memory (5) optional: /codex:review --scope branch --base 53d7292 to add Step 8 verification on top of approved P1 fix (6) Phase 6 Step 9 침습적 — ActionSender 시그니처 교체 + scope_provider 필드 추가 → app.rs 다수 호출부 컴파일 에러 도미노. Step 10 GREEN으로 일괄 fix. 두 Step 한 사이클 권장. +[2026-04-28T01:36:00Z] system-followup-bl | bl=BL-097 | repo=devflow-aidlc-like | issue=https://github.com/bluejayA/aidlc-devflow/issues/189 | pr=https://github.com/bluejayA/aidlc-devflow/pull/190 | scope=aidlc-pausing-a-session-skill+resume-drift-detect | seed=this-session-stale-recovery | priority=P2 +[2026-04-28T01:40:00Z] main-revert-incident | issue=session-end markers commit was mistakenly placed on main (commit 0bf81bc) instead of feature/bl-p2-085 worktree | resolution=git revert on main (bec4c05) + re-apply on worktree (this commit) | root-cause=Bash cwd reset between calls + insufficient branch verification before commit | followup=BL-097 should also enforce branch verification in stop-checklist +- 2026-04-28T15:29:04Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +- 2026-04-28T15:29:20Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-29T00:00:00Z] phase6-step9-10-complete | commit=1f80968 | tests=1413 (+3 ActionSender FR2 stamping) | clippy=clean | next=Phase-7-Step-11-worker-FR2-hook +[2026-04-29T00:00:00Z] session-end | reason=user-requested-stop-after-step-9-10 (option D) | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-6-Step-9-10-ActionSender-stamping | next-resume=Phase-7-Step-11-worker-FR2-hook (3-cycle 분할 권장 11a/11b/11c) | tests=1413 | regression=0 | commits-on-branch=10 (ca2ec2a/e68d50b/53d7292/626cd64/482af90/df646fd/b4b4c44/26ac5dc/73b347d/8401945/1f80968) | uncommitted=audit.md (this commit) | sot=devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-29T00:00:00Z] cross-session-handoff | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) git branch --show-current → feature/bl-p2-085-cross-project-scoping 확인 (3) git log -1 → this-commit 확인 (4) cargo test --lib → 1413 pass (5) project_nexttui.md memory 자동 로드 (6) optional: /codex:review --scope branch --base 8401945 — Step 9+10 외부 시각 점검 (7) Step 11은 큰 영향범위 — 11a (signature+hook), 11b (audit), 11c (toast) 3-cycle 분할 권장 +[2026-04-29T00:00:00Z] step-11-scope-decision | option=D-defer-to-next-session | reason=Step-11 영향범위 ~$2/300-500 lines 예상, 토큰/컨텍스트 부담 + 외부 리뷰 주기 분리 효과 | recommended-split=11a-signature+hook / 11b-audit / 11c-toast +- 2026-04-29T05:09:05Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-29T05:30:00Z] codex-review-step9-10 | scope=branch | base=1f80968~1 | verdict=approved | findings=0 | save=~/projects/docs/reviews/2026-04-29-bl-p2-085-phase6-step9-10-feat-bl-p2-085-codex.md | gate=11a-entry +[2026-04-29T05:35:00Z] phase7-step11a-complete | helper=worker::check_dispatched_origin | hook=run_worker recv-loop continue-on-block | tests=1415 (+2 origin guard) | clippy=clean | signature-unchanged | audit/toast=deferred-to-11b/11c | next=Step-11b-AuditLogger-integration +- 2026-04-29T05:45:50Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-29T06:00:00Z] phase7-step11b-complete | helpers=CrossProjectBlockEvent::new + worker::emit_origin_block_audit | signature-expansion=run_worker(+audit_logger:Option>,+actor_cloud:String,+actor_user_id:String) | refactor=App.audit_logger Option→Option> + audit_logger_arc() getter | correlation_id=action_epoch (Reviewer-Must-fix-#1 satisfied) | tests=1418 (+3 11b: new()/emit-with-logger/emit-none) | clippy=clean | flake-observed=keystone_project_directory::fetch_multi_page_follows_next_link (BL-P2-086 candidate, retry-pass) | follow-ups=resource_kind-enrichment + Keystone-UUID-resolution + RwLock-user_id-refresh | next=Step-11c-AppEvent-CrossProjectBlocked-variant+toast +- 2026-04-29T06:30:09Z — file-edit — devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-29T06:30:00Z] phase7-step11c-complete | variant=AppEvent::CrossProjectBlocked{reason:String,action:String} | helper=worker::make_cross_project_blocked_event | toast=App::generate_toast Error-level (PermissionDenied parity) | hook-final-shape=audit→event→continue | tests=1421 (+3 11c: readonly_bypass/helper_variant/toast_pushes_error) | clippy=clean | flake=keystone_project_directory mock-isolation observed-multi-threaded-pass-single-threaded (BL-P2-086) | next=Phase-8-Step-12-Neutron-_filter-fix +[2026-04-29T06:30:00Z] phase7-complete | step-11-split-summary=11a-hook + 11b-audit + 11c-toast | total-tests-added=8 (1413→1421) | commits=3 (1604113+f06a29c+TBD) | reviewer-must-fix-#1=satisfied (guard_layer + correlation_id) | next=Phase-8-Adapter-FR1 +[2026-04-29T07:30:00Z] phase7-polish-complete | reviewers=codex+cargo-review-multi-agent (Correctness/Style/Suggestions) | merged-findings=MED-#1-username-fallback + MED-#2-stale-actor-capture + Style-#9-rename | changes=worker::ActorContext + Arc live read + App.actor_ctx + ContextChanged cloud hook + wire_username/"unknown" fallback | tests=1422 (+1 actor_context_live_read) | clippy=clean | followups-still-open=resource_kind-enrichment + Keystone-UUID + token-refresh-user_id-hook + BL-P2-086 | next=Phase-8-Step-12-Neutron-_filter-fix +[2026-04-29T08:00:00Z] session-end | reason=phase-7-natural-gate (option B-stop-only, no-push) | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-7-Step-11abc-+-polish | next-resume=Phase-8-Step-12-Neutron-_filter-fix | tests=1422 (single-threaded; multi-thread BL-P2-086 mock-isolation flake) | regression=0 | commits-on-branch=16 (Phase 1-7 + polish 누적) | uncommitted=0 (clean tree) | atomic-decision-reaffirmed=true (no PR until Phase 11 complete) | reviews-passed=Codex(Step 9+10 + Phase 7) + cargo-review-multi-agent (HIGH 0, MED 2 → polished into 0b63233) | sot=devflow-docs/construction/bl-p2-085/code-plan.md +[2026-04-29T08:00:00Z] cross-session-handoff | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) git branch --show-current → feature/bl-p2-085-cross-project-scoping (3) git log -1 → 0b63233 폴리싱 (4) cargo test --lib -- --test-threads=1 → 1422 pass (5) project_nexttui.md memory 자동 로드 (6) Phase 8 Step 12 RED → code-plan.md line 274~ 참조 (7) ⚠️ atomic security 결정 — Phase 11 완료 전까지 push/PR 절대 금지 + +## 2026-05-06 + +[2026-05-06T01:55:00Z] session-resumed (5회차) | resume-point=Phase-8-Step-12 | baseline=1422-passed-single-threaded | branch-HEAD=5e47aac | clean-tree=true +[2026-05-06T02:00:00Z] phase8-step12-complete | bug-fixed=neutron-_filter-IGNORE (build_network_query/build_security_group_query/build_floating_ip_query) | filter-extension=NetworkListFilter+SecurityGroupListFilter+FloatingIpListFilter gain pub tenant_id:Option | worker-enrichment=run_worker rbac.project_id() snapshot per dispatch + handle_action +active_tenant arg + scoped_tenant_id derivation (None when all_tenants=true) | policy=all_tenants=1-OR-tenant_id={scope}-mutually-exclusive (no-op fail-safe when both absent) | tests=1426 (+4 net = +7 RED − 3 stale-regression deletions: 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) | clippy=clean | fmt=touched-files-clean (pre-existing fmt diffs in app.rs/action_channel.rs/etc deferred to Phase-11-final-pass) | commit=fa5efaa | next=Phase-8-Step-13-adapter-response-refilter+AdapterFilterViolation-event +[2026-05-06T02:00:00Z] session-end (5회차) | reason=mid-cycle-stop-after-step12 (option-A-incremental-safety, no-push) | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-8-Step-12-Neutron-tenant_id-injection | next-resume=Phase-8-Step-13-adapter-response-refilter | tests=1426 (single-threaded; multi-thread BL-P2-086 mock-isolation flake still applies) | regression=0 | commits-on-branch=18 (Phase 1-7+polish + Step-12) | uncommitted=audit.md+state.md (this chore commit pending) | atomic-decision-reaffirmed=true (no PR until Phase 11 complete) | sot=devflow-docs/construction/bl-p2-085/code-plan.md +[2026-05-06T02:00:00Z] cross-session-handoff (5회차) | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) git branch --show-current → feature/bl-p2-085-cross-project-scoping (3) git log -1 → expected chore-marker (HEAD에서 fa5efaa 직전 commit이 Step 12) (4) cargo test --lib -- --test-threads=1 → 1426 pass (5) project_nexttui.md memory 자동 로드 (6) Phase 8 Step 13 RED → code-plan.md line 313~ 참조 (HasTenantId trait + scope_refilter pure fn + 6 RED tests + AuditLogger 필드 주입 + AdapterFilterViolation event emit per dropped item) (7) ⚠️ atomic security 결정 — Phase 11 완료 전까지 push/PR 절대 금지 +[2026-05-06T05:00:00Z] phase8-step13a-complete | new-file=src/adapter/http/scope_refilter.rs (HasTenantId trait + refilter_by_scope pure fn) | mod.rs +1 export | tests=1431 (+5: drops_strict + keeps_all_when_all_tenants_true + keeps_active + drops_missing_tenant_fail_safe + no_op_when_active_none) | clippy=clean | cargo-review=Multi-Agent Full (Correctness APPROVE / Style APPROVE 1 MED resolved / Suggestions 6 (3 deferred to 13b decision points: RefilterScope struct + resource_id sig + trait rename) | doc-polish=trait-method-docs + lazy-alloc-warning + None-tenant-event-contract | commit=021dbe0 | next=Phase-8-Step-13b-RED-then-GREEN +[2026-05-06T10:03:00Z] phase8-step13b-RED-only | reason=user-option-C-incremental-impact-measurement | red-tests=5 (test_network_has_tenant_id_returns_some/none + test_security_group_has_tenant_id + test_floating_ip_has_tenant_id_returns_some/none) | location=src/adapter/http/scope_refilter.rs::tests (sample_network/sample_security_group/sample_floating_ip helpers + use crate::models::neutron::{Network,SecurityGroup,FloatingIp}) | red-verify=10-compile-errors-E0599 (method `tenant_id`/`resource_id` not found for Network/SecurityGroup/FloatingIp — HasTenantId impl 미존재, 정확한 RED) | uncommitted=src/adapter/http/scope_refilter.rs (unstaged, +85 lines for RED tests) | impact-scope-measured=4-files (scope_refilter.rs HasTenantId impl 3 + neutron.rs audit_logger field + 3 list_* refilter wiring + emit / registry.rs:47 new_http signature / main.rs registry caller) | NeutronHttpAdapter::from_base call-sites=1 (registry.rs:64 only — blast smaller than initial estimate) | model-fields-confirmed=Network.tenant_id:Option + SecurityGroup.tenant_id:Option + FloatingIp.tenant_id:Option (all `.id: String`) | next-resume=Phase-8-Step-13b-GREEN +[2026-05-06T10:03:00Z] session-end (6회차) | reason=mid-cycle-stop-after-step13b-RED-only (option-C-impact-measurement, no-push) | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-8-Step-13a-commit + Phase-8-Step-13b-RED-uncommitted | next-resume=Phase-8-Step-13b-GREEN-impl (HasTenantId for Network+SecurityGroup+FloatingIp 추가 → 5 RED → 5 GREEN, then continue with NeutronHttpAdapter audit_logger field + 3 list_* refilter wiring + emit + registry/main caller updates) | tests=1431 (lib build is broken due to RED tests in scope_refilter.rs — intentional, GREEN restores) | regression=0 | commits-on-branch=20 (Phase 1-7+polish + Step-12 + 12-chore + Step-13a) | uncommitted=src/adapter/http/scope_refilter.rs (RED tests +85 lines, intentional broken build) | atomic-decision-reaffirmed=true (no PR until Phase 11 complete) | sot=devflow-docs/construction/bl-p2-085/code-plan.md (Step 13) +[2026-05-06T10:03:00Z] cross-session-handoff (6회차) | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) git branch --show-current → feature/bl-p2-085-cross-project-scoping (3) git log -1 → 021dbe0 Step 13a (4) git status → expect ` M src/adapter/http/scope_refilter.rs` (RED tests untracked-mod) (5) cargo test --lib --no-run → expect 10 E0599 errors (intentional RED) (6) project_nexttui.md memory load (7) Phase 8 Step 13b GREEN: add `impl HasTenantId for Network/SecurityGroup/FloatingIp` (each maps tenant_id → tenant_id() and id → Some(&self.id)). Once 5 tests green (1431 → 1436), proceed to next 13b layer: NeutronHttpAdapter `audit_logger: Option>` + `active_project_provider: Arc` 필드 추가, 3 list_* impl 내부에 refilter_by_scope + dropped iterate + cross_project_audit::emit, registry.rs::new_http 시그니처 확장, main.rs registry caller 갱신. AdapterFilterViolation event는 CrossProjectBlockEvent::new(reason=AdapterFilterViolation{resource_id,project_id}, GuardLayer::Fr1Adapter, action_type="adapter_list", resource_kind=neutron-resource-name, ...) (8) ⚠️ atomic security 결정 — Phase 11 완료 전까지 push/PR 절대 금지 + +## 2026-05-07 + +[2026-05-07T03:50:00Z] session-resumed (7회차) | resume-point=Step-13b-GREEN | RED-uncommitted-confirmed=10-E0599 errors as designed | tests-at-HEAD=1431 +[2026-05-07T03:55:00Z] phase8-step13b-1-complete | impls=HasTenantId for Network/SecurityGroup/FloatingIp (모두 동일 shape: tenant_id() = self.tenant_id.as_deref(), resource_id() = Some(&self.id)) | location=src/adapter/http/scope_refilter.rs (use crate::models::neutron::{FloatingIp,Network,SecurityGroup} prod-area + 3 impl blocks following refilter_by_scope) | tests=1436 (+5: 6회차 RED tests turn green — test_network_has_tenant_id_returns_some/none + test_security_group_has_tenant_id_returns_some + test_floating_ip_has_tenant_id_returns_some/none) | clippy=clean | fmt=touched-file-clean | commit=0e66271 | next=Step-13b-2 NeutronHttpAdapter wiring +[2026-05-07T04:00:00Z] step13b-2-design-frozen | scope=this-session-not-implemented (user pause before code) | design: NeutronAuditCtx struct {logger:Arc, scope_provider:Arc, actor_ctx:Arc>} + impl NeutronAuditCtx::emit_filter_violations(dropped, action_type, resource_kind, correlation_id) | NeutronHttpAdapter +Option> 필드 + with_audit(ctx) builder (기존 from_base 유지, audit_ctx default None) | 3 list_* impl 본체에 audit_ctx Some 분기에서 refilter_by_scope + emit_filter_violations + PaginatedResponse{items:kept,..resp} 재구성 | registry.rs/main.rs 갱신은 13b-3로 분리 (audit_ctx Option이라 13b-2에서 registry 무수정 가능, build-clean) | RED-test-plan(5): test_neutron_audit_ctx_emit_one_event_per_dropped (tempdir AuditLogger fixture) + _no_emit_when_dropped_empty + _uses_fr1_adapter_layer_in_event + _uses_adapter_filter_violation_reason + test_neutron_with_audit_attaches_ctx_default_none +[2026-05-07T04:00:00Z] session-end (7회차) | reason=user-pause-before-13b-2-code | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-8-Step-13b-1-HasTenantId-impls (commit 0e66271) | next-resume=Phase-8-Step-13b-2-RED-then-GREEN (NeutronAuditCtx + emit_filter_violations + Adapter wiring per design above) | tests=1436 (clean baseline, no broken build) | regression=0 | commits-on-branch=22 (Phase 1-7+polish + Step-12 + 12-chore + Step-13a + 13a-chore + Step-13b-1) | uncommitted=0 (clean tree) | atomic-decision-reaffirmed=true (no PR until Phase 11 complete) | sot=devflow-docs/construction/bl-p2-085/code-plan.md (Step 13) +[2026-05-07T04:00:00Z] cross-session-handoff (7회차) | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) git branch --show-current → feature/bl-p2-085-cross-project-scoping (3) git log -1 → 0e66271 Step 13b-1 (4) git status → clean (5) cargo test --lib -- --test-threads=1 → 1436 pass (6) project_nexttui.md memory load + state.md "Step 13b-2 design" 섹션 확인 (7) Step 13b-2 RED 5 tests 작성 (NeutronAuditCtx::emit_filter_violations + tempdir AuditLogger fixture pattern은 src/infra/cross_project_audit.rs::tests의 test_emit_with_logger_writes_audit_entry 참조) → Verify RED → GREEN: NeutronAuditCtx struct 추가 + emit_filter_violations impl + NeutronHttpAdapter audit_ctx field + with_audit builder + 3 list_* wiring (8) Step 13b-3는 별 commit (registry::new_http 시그니처 확장 + main.rs caller. App.audit_logger_arc()와 RbacGuard arc 그대로 주입) (9) Step 13b GREEN 전체 완료 후 /codex:review --scope branch --base fa5efaa~1 또는 /cargo-review 권장 (Phase 8까지 cumulative) (10) ⚠️ atomic security 결정 — Phase 11 완료 전까지 push/PR 절대 금지 +[2026-05-07T08:30:00Z] session-resumed (8회차) | resume-point=Step-13b-2-RED→GREEN | tests-at-HEAD=1436 | clean-tree=true +[2026-05-07T08:50:00Z] phase8-step13b-2-complete | commit=f2e66f3 | new-file=neutron_audit.rs (NeutronAuditCtx struct + emit_filter_violations) | adapter-changes=NeutronHttpAdapter +audit_ctx:Option> field + with_audit builder + private refilter_response helper + 3 list_* wiring (FetchNetworks/FetchSecurityGroups/FetchFloatingIps) | tests=1441 (+5 RED→GREEN: emit_one_per_dropped/no_emit_when_empty/uses_fr1_adapter_layer/uses_adapter_filter_violation_reason/with_audit_attaches_ctx) | clippy=clean | tempdir-AuditLogger-fixture-pattern=cross_project_audit.rs::tests parity | next=Step-13b-3 +[2026-05-07T09:10:00Z] phase8-step13b-3-complete | commit=fd2d2e5 | registry-sig-change=new_http(+neutron_audit:Option>) | main-flow-reorder=App→audit_logger_arc→actor_ctx→build NeutronAuditCtx→AdapterRegistry::new_http (cloud_region clone preempts E0505 borrow conflict) | NeutronHttpAdapter::new also takes audit_ctx=None default for parity | tests=1441 (no new — wiring only) | clippy=clean | bin-compile=OK | Phase-8-feature-complete=true (Step 12 + 13a + 13b-1 + 13b-2 + 13b-3 all committed) +[2026-05-07T09:20:00Z] phase8-cumulative-cargo-review | scope=base-9b79a99-to-HEAD-fd2d2e5 (850 lines, 6 .rs + 1 .md, Multi-Agent Full) | agents=A-Correctness-APPROVE (HIGH 0, MED 0, LOW 3) + B-Style-APPROVE (HIGH 0, MED 1, LOW 6) + C-Suggestions-10-items | verdict=APPROVE-WITH-MINOR-DOC-POLISH + Step-14-precedent-refactor-cycle | immediate-actions-applied=5 (Style MED #1 NeutronAuditCtx field docs / Style LOW #5 correlation_id=0 TODO comment / Style LOW #6 main.rs use shortening / Style LOW #7 scope_refilter tests use re-grouped / Correctness LOW #3 + Suggestions READABILITY #4 event.resource_id duplicate-set comment) | deferred-to-Step-14-precedent=DRY-refactor-strong (Suggestions DRY #1 lift refilter_response to scope_refilter::refilter_and_audit + DRY #2 generic AuditCtx{logger,scope_provider,actor_ctx,service} + 3 type aliases + DRY #8 AdapterAuditConfig struct) + 13a-deferred-4 (RefilterScope struct/trait rename ScopedItem/resource_id keep/fixture-helper-keep) | rationale=동일 패턴을 Nova/Cinder에 복제하기 전이 추상화 비용 최저 +[2026-05-07T09:30:00Z] phase8-polish-complete | commit=da38cb9 | files=4 (neutron.rs/neutron_audit.rs/scope_refilter.rs/main.rs) | tests=1441 (no count change — behavioral neutrality) | clippy=clean | .cargo-review.toml=removed (temporary base scope file) +[2026-05-07T09:35:00Z] session-end (8회차) | reason=phase-8-feature-complete + cumulative-review-applied (no-push) | phase=CONSTRUCTION | stage=code-generation | last-completed=Phase-8-Step-12+13a+13b-1+13b-2+13b-3+polish | next-resume=Step-14-precedent-refactor-cycle (DRY: lift refilter_response generic + AuditCtx generic + AdapterAuditConfig + 13a-deferred-4 결정점 재검토) → 그 후 Step 14 (Nova/Cinder defense-in-depth refilter, HasTenantId impl Server/Volume/Snapshot, AuditCtx for Nova/Cinder) | tests=1441 (clean baseline) | regression=0 | commits-on-branch=26 (Phase 1-7+polish + Step-12 + 12-chore + Step-13a + 13a-chore + Step-13b-1 + 13b-1-chore + Step-13b-2 + Step-13b-3 + Phase-8-polish) | uncommitted=audit.md+state.md (8회차 chore commit pending) | atomic-decision-reaffirmed=true | sot=devflow-docs/construction/bl-p2-085/code-plan.md (Step 14 line 354~) +[2026-05-07T09:35:00Z] cross-session-handoff (8회차) | resume-instructions: (1) cd .worktrees/bl-p2-085-cross-project-scoping (2) git log -1 → expected chore-marker (HEAD) or da38cb9 polish (3) git status → clean (4) cargo test --lib -- --test-threads=1 → 1441 pass (5) project_nexttui.md + state.md load — "Step-14-precedent-refactor-cycle" 섹션 확인 (6) Refactor cycle 진입: scope_refilter에 generic refilter_and_audit + AuditCtx generic struct (logger/scope_provider/actor_ctx/service) + 3 type alias (NeutronAuditCtx/NovaAuditCtx/CinderAuditCtx) + AdapterAuditConfig{neutron,nova,cinder} → registry::new_http(auth, region, audit_config) → 13a-deferred-4 결정점 재검토 (RefilterScope struct/trait rename ScopedItem/resource_id keep/fixture-helper-keep) → behavioral neutrality TDD (기존 1441 tests intact + 신규 generic helper unit tests) (7) Refactor 끝나면 Step 14 (Nova/Cinder defense-in-depth refilter): HasTenantId impl Server/Volume/Snapshot + Nova/Cinder list_* wiring + AuditCtx 인스턴스 4개로 재구성 (8) ⚠️ atomic security 결정 — Phase 11 완료 전까지 push/PR 절대 금지 +- 2026-05-11T10:23:03Z — file-edit — devflow-docs/backlog.md +- 2026-05-12T04:17:11Z — file-edit — devflow-docs/backlog.md diff --git a/devflow-docs/backlog.md b/devflow-docs/backlog.md index 6d3a80f..6d8399b 100644 --- a/devflow-docs/backlog.md +++ b/devflow-docs/backlog.md @@ -243,6 +243,146 @@ BL-P2-080 Unit 3(`.github/workflows/ci.yml::devstack-integration`)은 placeholde **Ref**: 2026-04-21 Codex adversarial-review v3 finding M1, BL-P2-081 옵션 β 타협안 follow-up. +### BL-P2-089: Glance `UpdateImage` Action + FR4 cross-project guard (BL-P2-085 follow-up) +**Priority**: Medium +**Parent**: BL-P2-085 Step 16 (descope) +**Category**: Security / Functional + +**Description**: BL-P2-085 Step 16에서 `Action::DeleteImage` 경로에는 FR4 pre-mutation 가드 (Glance `get_image` → `check_image_owner_scope` → `Fr4Form` audit + reject)를 적용했으나, image **update** path는 `Action::UpdateImage` variant 자체가 미존재해 가드를 적용할 곳이 없었음. Glance `update_image` adapter는 이미 존재하지만 worker가 호출하지 않음. + +본 BL에서: +1. `Action::UpdateImage { id, params: ImageUpdateParams }` variant 신규 (`src/action.rs`) +2. `module/image/mod.rs`에 update 키 핸들러 (`PendingAction::UpdateImage` 또는 직접 form submit) +3. `worker.rs::action_to_kind`에 `Action::UpdateImage => ActionKind::Update` 분류 + `action_name` 매핑 추가 +4. `worker.rs::handle_action(Action::UpdateImage)` 분기 — `DeleteImage`와 동일 패턴: + - pre-mutation `glance.get_image(&id).await` + - `check_image_owner_scope(&image, active)` 호출 (Step 16 pure helper 재사용) + - Block이면 `emit_form_block_audit(reason, "UpdateImage", "image", &id, ...)` + `AppEvent::CrossProjectBlocked { reason, action: "UpdateImage" }` + - 진행 시 `glance.update_image(&id, ¶ms).await` +5. 3 신규 tests (worker.rs::tests): + - `test_update_image_rejects_cross_project_owner` + - `test_update_image_allows_same_project_owner` + - `test_update_image_emits_fr4_form_event` — `emit_form_block_audit`로 "UpdateImage" action_type stamp 확인 + +**Out of scope**: image visibility/min_disk/min_ram 등 update field 전체 surface — 본 BL은 FR4 가드 적용에 집중. Form UI 추가/변경은 별도 cycle. + +**Ref**: BL-P2-085 Step 16 commit `e91cca1` ("Plan adjustment: descoped `UpdateImage` to a follow-up BL because no `Action::UpdateImage` variant exists today"). + +### BL-P2-090: `MockGlanceWithImage` configurable mock for `handle_action` FR4 integration test (BL-P2-085 follow-up) +**Priority**: Low +**Parent**: BL-P2-085 Step 16 (test gap) +**Category**: Testing Infrastructure + +**Description**: BL-P2-085 Step 16의 pure helper (`check_image_owner_scope` / `emit_form_block_audit`) 5 tests로 결정 매트릭스는 cover됐으나, `handle_action(Action::DeleteImage)` **통합 경로** 자체는 단위 테스트로 검증되지 않음. 원인: 기본 `MockGlanceAdapter::get_image`가 `Err(ApiError::NotFound)`만 반환하므로 cross-project owner 시나리오를 `handle_action`에 끝까지 흘려보낼 수 없음. + +본 BL에서: +1. `MockGlanceAdapter`에 configurable behavior 추가 — 다음 중 1택: + - Option (a): `MockGlanceAdapter::with_image(Image)` builder + 내부 `Mutex>`로 get_image 반환 제어 + - Option (b): 별 mock struct `MockGlanceWithImage { owner: Option, delete_called: AtomicBool }`로 분리 + - Option (c): 더 일반화된 `MockAdapter` 패턴 도입 (다른 adapter mock에도 확장 가능) +2. `worker.rs::tests`에 통합 test 추가: + - `test_handle_action_delete_image_emits_cross_project_blocked_when_owner_mismatch` — AdapterRegistry(with mock) + active=A → `handle_action` returns `AppEvent::CrossProjectBlocked`, mock이 `delete_image`를 호출받지 않았는지 확인 (Atomic flag) + - `test_handle_action_delete_image_proceeds_when_owner_matches` — owner=A + active=A → audit log empty, `delete_image` 호출됨 + - `test_handle_action_delete_image_falls_through_on_pre_get_error` — pre-GET이 NotFound 등이면 기존 delete 흐름 (현재 동작 보존) + +**Out of scope**: 다른 adapter mock 일반화 (별도 BL). Option (c)를 선택할 경우 다른 adapter에도 적용 여부는 follow-up. + +**Ref**: BL-P2-085 Step 16 commit `e91cca1` ("Pure helpers cover the decision matrix; `handle_action` integration is not unit-tested directly because `MockGlanceAdapter::get_image` returns `NotFound`"). + +### BL-P2-091: Glance FR1 — `ScopedItem for Image` + `GlanceHttpAdapter::with_audit` (BL-P2-085 follow-up) +**Priority**: Medium +**Parent**: BL-P2-085 cargo-review branch-full Correctness #1 / Suggestions #4 +**Category**: Security / Functional + +**Description**: BL-P2-085가 Neutron/Nova/Cinder 3개 list adapter에 FR1 (`refilter_response` + `AdapterFilterViolation` audit emit)을 wire했으나, GlanceHttpAdapter는 **FR1 미적용**. 결과적으로 `FetchImages`가 admin 토큰 등에서 cross-project image를 list UI에 노출할 수 있고, 차단은 FR4 (DeleteImage pre-mutation) 경로에서만 발생. atomic security PR의 "FR1+FR2+FR3+FR4 layered defense" 약속과 비대칭. + +본 BL에서: +1. `src/adapter/http/scope_refilter.rs`에 `impl ScopedItem for Image` 추가 — `Image.owner: Option` → `tenant_id()`, `Image.id: String` → `resource_id()` +2. `src/adapter/http/glance.rs`: + - `audit_ctx: Option>` 필드 + `with_audit(ctx)` builder (Nova/Cinder/Neutron 패턴 mirror) + - `refilter_response` helper + - `list_images` 본체에서 `refilter_response(resp, filter.all_tenants, "FetchImages", "image")` 호출 +3. `src/adapter/http/neutron_audit.rs`에 `pub type GlanceAuditCtx = AuditCtx;` + `AdapterAuditConfig.glance: Option>` 필드 추가 + `build_audit_config`에서 `glance` 채우기 +4. `src/adapter/registry.rs::new_http` body에 `audit.glance` 소비 (`glance.with_audit(ctx)`) +5. 신규 tests: `test_image_has_scoped_item_returns_some_when_present` / `_returns_none_when_absent` / `_build_audit_config_returns_glance_with_service_glance` / `test_glance_with_audit_attaches_ctx_default_none` + +**Out of scope**: Glance image visibility 의미론 (`public`/`private`/`shared`/`community`)의 정밀한 처리 — `owner` 비교만으로 부족할 수 있는 케이스는 별도 BL. + +**Ref**: cargo-review branch-full report 2026-05-12 (Correctness #1, Suggestions #4). + +### BL-P2-092: Cross-resource pre-mutation FR4 (FIP/port, Volume/server project mismatch) (BL-P2-085 follow-up) +**Priority**: Medium +**Parent**: BL-P2-085 cargo-review branch-full Suggestions #5 +**Category**: Security / Functional + +**Description**: BL-P2-085 FR4 (`check_image_owner_scope` / `validate_form_scope`)는 단일 resource의 owner-vs-active 비교만 처리. 그러나 cross-resource mutation은 두 resource가 서로 다른 project에 속할 가능성에 대한 pre-mutation check가 부재: +- `AssociateFloatingIp { fip_id, port_id }` — fip가 proj-A, port가 proj-B 소속일 수 있음 +- `DisassociateFloatingIp { fip_id }` — fip가 active scope와 다른 project일 수 있음 +- `AttachVolume { volume_id, server_id }` — volume과 server가 다른 project일 수 있음 +- `DetachVolume` / `ForceDetachVolume` — 동일 +- `LiveMigrateServer` / `Evacuate` 등 — destination host가 다른 project AZ일 수 있음 (별 BL 영역일 수도) + +현재는 OpenStack 서버측 RBAC에 의존 — admin 토큰이면 통과 가능. atomic security의 client-side defense-in-depth가 비대칭. + +본 BL에서: +1. `Action::AssociateFloatingIp` 분기에 pre-mutation GET 두 번 (fip + port) → 각자 project_id 추출 → active_tenant와 비교 → 첫 mismatch면 `Fr4Form` audit emit + `AppEvent::CrossProjectBlocked` +2. `Action::AttachVolume` / `DetachVolume` / `ForceDetachVolume` 동일 패턴 (volume + server) +3. `Action::DisassociateFloatingIp` — fip만 비교 +4. helper `check_pair_scope(left_project, right_project, active)` pure fn 도입 (이번 BL의 단일 resource helper 일반화) +5. 5+ tests — 각 action별 cross-project deny + same-project allow + missing project_id fail-safe + +**Out of scope**: Live-migrate destination host AZ scope (BL-P2-086 직후 영역). Phase 9 plan에는 FIP/Volume만 있었음. + +**Ref**: cargo-review branch-full report 2026-05-12 (Suggestions #5). + +### BL-P2-093: `actor_ctx.user_id` cloud-switch live sync (BL-P2-085 follow-up) +**Priority**: High +**Parent**: BL-P2-085 cargo-review branch-full Suggestions #1 +**Category**: Security / Audit Attribution + +**Description**: BL-P2-085 Phase 7 폴리싱에서 `actor_ctx.cloud`는 `App::handle_event(ContextChanged)`에서 live update 되도록 wire됐으나, `actor_ctx.user_id`는 **wire-startup 시점의 `wire_username`으로 고정**. 즉 사용자가 cloud-A → cloud-B로 switch한 뒤 다른 자격증명으로 재인증해도, 그 후 발생하는 모든 cross-project block audit entry는 **cloud-A의 user_id를 사용**한다. + +영향: +1. Audit attribution 손상 — multi-cloud 환경에서 누가 block을 trigger했는지 잘못 기록 +2. **fingerprint v1 dedup 깨짐** — canonical `"v1|user|active|origin|target|action|resource_id"`에 user가 포함됨 → 동일 user가 두 cloud에서 동일 cross-project pattern 시도 시 fingerprint 다르게 생성 → dedup 미발동 + +본 BL에서: +1. `ContextTarget` 또는 `ContextChanged` event payload에 새 토큰의 `user.id` 포함 (Keystone token의 user UUID 사용) +2. `App::handle_event(ContextChanged)`에서 `actor_ctx.write().user_id = new_user_id`로 갱신 +3. `KeystoneAuthAdapter::get_token_info()`이 user_id를 노출하는지 확인 + wire +4. 신규 test: `test_actor_ctx_user_id_updates_on_cloud_switch` (RwLock mutate → 다음 emit이 새 user_id 반영) — Phase 7 폴리싱의 `test_emit_origin_block_audit_picks_up_actor_context_mutation` 패턴 mirror + +**Out of scope**: token refresh 중 user_id 변경 hook (BL-P2-052의 token refresh 작업과 결합). Username/UUID 매핑 (현재는 UUID 우선). + +**Ref**: cargo-review branch-full report 2026-05-12 (Suggestions #1). + +### BL-P2-094: Fingerprint v2 schema — `|` escape + length-prefix (BL-P2-085 follow-up) +**Priority**: Low (v2 cycle 시 필수) +**Parent**: BL-P2-085 cargo-review branch-full Suggestions #2 +**Category**: Schema Hardening + +**Description**: BL-P2-085 fingerprint v1 canonical은 `"v1|user|active|origin|target|action|resource_id"` 형태로 field 값을 unescape된 채 `|`로 join. 어느 하나가 `|`를 포함하면 collision 가능: +- `(user="a|b", active="c")` ↔ `(user="a", active="b|c")` 동일 fingerprint +- Keystone user_id는 UUID라 안전하지만 `action_type` (예: "Action|with|pipe"), `resource_id` (사용자 정의 가능한 경우) 등 customizable fields는 위험 + +v1 schema는 LOCKED라 즉시 변경 X. **다음 schema bump 시점에 반드시 escape rule을 같이 도입**. + +본 BL에서: +1. fingerprint v2 canonical 설계: + - 옵션 (a): `\|` / `\\` escape rule + version prefix `"v2|escaped(user)|escaped(active)|..."` + - 옵션 (b): length-prefix encoding `"v2|3:foo|5:bar|..."` (collision 불가, simple) + - 옵션 (c): JSON canonicalization (serde_json with `sort_keys`) — 가독성 ↓이지만 표준 +2. v1 → v2 migration: 기존 v1 entry 보존 (rotation 이전 데이터). 새 entry는 v2. +3. `CrossProjectBlockEvent::fingerprint` 갱신 + `tests/cross_project_audit.rs` schema-stable tests 갱신 +4. v1 LOCKED 약속 해제 — state.md "Schema-Stable 결정" 갱신 +5. **호환성**: audit consumer (grep/jq script 등)는 v1/v2 둘 다 지원하도록 release notes + +**Out of scope**: hex 변환 최적화 (cargo-review Suggestions #6, byte-level lookup) — 본 BL과 함께 진행하면 자연. + +**Trigger**: 새 schema field 추가 (예: `service` from refactor-3 / `correlation_id` epoch propagation) 또는 v1 LOCKED 해제 결정 시. + +**Ref**: cargo-review branch-full report 2026-05-12 (Suggestions #2). + ### BL-P2-052: Rescoped 토큰 자동 refresh + ContextChanged handler **Priority**: High (Part A 기준) — **BL-P2-080 / BL-P2-081 / BL-P2-083 이후** **Category**: Auth / Functional Regression diff --git a/devflow-docs/construction/bl-p2-074/cargo-review-report.md b/devflow-docs/construction/bl-p2-074/cargo-review-report.md deleted file mode 100644 index c18a72d..0000000 --- a/devflow-docs/construction/bl-p2-074/cargo-review-report.md +++ /dev/null @@ -1,98 +0,0 @@ -# Cargo Review Report — BL-P2-074 (SwitchCloud wire 완결) - -## Summary -- **변경 파일**: 9개 Rust (수정 9 / 생성 0 / 삭제 0) + devflow-docs -- **Diff 규모**: 698 lines (+429 / -44) -- **테스트**: ✅ PASS (1328 통과, 0 실패 — baseline 1314 + 14 신규) -- **Clippy**: ✅ PASS (`cargo clippy --lib --tests -- -D warnings` clean) -- **Fmt**: ✅ PASS (`cargo fmt --all --check` clean) -- **Bin Build**: ✅ PASS (`cargo build --bin nexttui`) -- **테스트 커버리지**: 7/7 변경 컴포넌트 (동일 파일 내 `#[cfg(test)]`) -- **리뷰 모드**: Multi-Agent (3 reviewers, diff > 100줄) -- **포커스**: Full - ---- - -## 1. Correctness (정확성) - -| # | 파일:라인 | 심각도 | 이슈 | 제안 | -|---|----------|--------|------|------| -| C1 | `src/context/resolver.rs:120` | LOW | `CloudOnly` → `resolve_by_name_inner` 경로에서 `normalize_cloud_project`의 `split_once('/')` 파싱이 발동. `default_project` 값에 `/`가 포함되면 prefix_cloud로 파싱되어 project 이름이 슬래시 뒤 토큰만 남음. cloud는 `cloud_arg`가 보호하므로 오염 없음, Keystone 조회는 NotFound로 실패 → 데이터 손상 없음. | CloudOnly 경로는 `normalize_cloud_project`를 우회하거나 project 이름에 `/` 포함 시 경고 tracing. 회귀 테스트로 `test_cloud_only_with_slash_in_default_project` 추가 검토. | -| C2 | `src/context/error.rs:59-60` | LOW | `SwitchError::Clone`의 `Api/Io` arm이 `CommitFailed`로 collapse — pre-begin 단계 에러도 "commit failed" 라벨로 오인 가능. 내부 진단용 주석 존재. | 필요 시 `Api` 전용 placeholder variant 신설. 현재는 주석으로 충분. | -| C3 | `src/context/types.rs:19` | LOW | `ContextRequest`에 `#[non_exhaustive]` 부재 — 외부 크레이트 재수출 여부 확인 필요. 현재 크레이트는 binary only. | binary이므로 무해. 향후 lib 분리 시 `#[non_exhaustive]` 추가. | - -**참고 (이슈 아님)**: `src/context/switcher.rs:82-93` idempotent path의 TOCTOU는 코드 주석에 명시 + FR-4 acceptance가 순차 호출 한정으로 정의됨. - -### 정확성 확인 사항 (Good) -- `run_switch_to` step 7의 `previous_in_flight()` 호출 시점 올바름. -- `switch_back` peek-not-pop 동작이 실패 경로에서 history 보존. -- `worker.rs`의 `Action::SwitchContext | SwitchBack => None` fall-through 방어 유효. -- `CloudConfig::default_project` `#[serde(default)]`로 backward-compat. -- 신규 프로덕션 코드에 `.unwrap()`/`.expect()` 추가 없음. -- `SwitchError::NotConfigured` Clone arm 안전. -- `Action::SwitchCloud` dead code 제거 후 exhaustiveness 유지. - -**HIGH/MED 없음**. - ---- - -## 2. Style (스타일) - -| # | 파일:라인 | 심각도 | 이슈 | 제안 | -|---|----------|--------|------|------| -| S1 | `src/context/error.rs:28` | MED | `SwitchError::NotConfigured { cloud }` struct variant가 기존 `NotFound(String)` / `Unsupported(String)` / `RescopeRejected(String)` tuple variant와 형식 불일치. (동일 enum에 `Ambiguous { candidates }` struct 전례 존재). | 단일 필드면 tuple로 통일하거나, struct 유지 시 "reason 확장 여지" doc comment 명시. application-design.md D2 결정 유지 권장. | -| S2 | `src/context/resolver.rs:110-114` | LOW | `tracing::warn!`이 `info_span!` 진입 전 발생 → span 컨텍스트 밖 로그. | warn을 `_enter` 이후로 이동하거나 `warn!(cloud = %cloud, ...)`로 필드 명시하여 span 의존성 제거. | -| S3 | `src/context/switcher.rs:79-88` | LOW | `crate::context::state_machine::SwitchStateView::Idle` full path 사용 — 파일 내 타입은 대체로 use로 끌어옴. | 파일 상단 `use crate::context::state_machine::SwitchStateView;` 추가. | -| S4 | `src/app.rs:1776` | LOW | `crate::context::ContextRequest::CloudOnly { cloud: name }` full path. | 파일 상단 use 추가 또는 `crate::context::ContextRequest` 부분만 import. | -| S5 | `src/context/resolver.rs:62-64` | LOW | trait `CloudDirectory`의 `default_project` doc comment가 상세한 반면 `active_cloud`/`known_clouds`는 doc 없음. Coverage 불일치. | 다른 두 메서드에도 한줄 doc 추가하거나 trait-level doc에서 묶어 설명. | -| S6 | `src/context/types.rs:29-31` | LOW | `CloudOnly` doc comment가 BL 참조 위주, 의미 설명 간략. | "no explicit project/domain — `CloudDirectory::default_project` provides resolution" 형식 추가. | -| S7 | tracing 이벤트 전반 | LOW | `info_span` vs `debug!` vs `warn!` 혼용 — 의도적 구분 (span vs event)이지만 프로젝트 다른 tracing 패턴과의 일관성 확인 필요. | 현재 level 분리는 의도적. 이벤트 네이밍 컨벤션(snake_case 유지) 점검으로 충분. | - ---- - -## 3. Suggestions (제안) - -| # | 파일:라인 | 유형 | 제안 | -|---|----------|------|------| -| P1 | `src/context/resolver.rs:169-175` (validate_cloud) | PERF | `known_clouds()`가 매 호출마다 `Vec` 재할당. `CloudOnly` 경로에서 `validate_cloud` + `normalize_cloud_project` 내부 체크 중복 → `default_project` 취득 직후 `resolve_by_name_inner`에서 한 번 더 발생 (총 3회). `trait`에 `fn contains_cloud(&self, name: &str) -> bool` 추가하거나 중복 체크 제거로 할당 감소. | -| R1 | `src/context/switcher.rs:82-93` | READABILITY | let-chain 패턴이 길어 가독성 저하. `SwitchStateMachine::committed_target() -> Option` 헬퍼 추가하면 `if self.state.committed_target().is_some_and(\|s\| s.target == target)` 형태로 평탄화. 테스트 fixture 재사용에도 도움. | -| R2 | `src/context/resolver.rs:236-260` (tests) | READABILITY | `clouds` 헬퍼와 `clouds_with_defaults` 헬퍼가 필드 구성만 다름. `FakeClouds::new(active, known).with_defaults(&[..])` builder 패턴으로 통합 가능. 테스트 전용, 리스크 0. | -| I1 | `src/context/types.rs:19` (CloudOnly) | IDIOM | variant 이름이 "cloud-scoped Keystone token"과 오독 위험. Keystone 맥락에서 "cloud-scoped"는 다른 의미. `ByCloud` rename 고려. Breaking 아님 (내부 enum). | -| I2 | `src/context/error.rs` Clone impl 전반 | IDIOM | `Api(_)`/`Io(_)`를 `Arc`/`Arc`로 감싸면 `#[derive(Clone)]`으로 수동 impl 제거 가능. BL 범위 외, 별도 cleanup BL. | - -### 변경 권장하지 않는 검토 포인트 -- `CloudDirectory::default_project` 반환 `Option` — `Option<&str>`로 변경 시 FakeClouds `HashMap` lifetime 얽힘으로 결국 `.to_string()` 필요. 현 시그니처 유지. -- `resolve_by_name_inner` 헬퍼 추출 — async 재귀 회피 목적으로 적절. -- `tracing::warn!`의 `ok_or_else` 내부 side-effect — 관용적이며 주석 가능. - ---- - -## 4. Verdict - -### ✅ APPROVE - -**기준**: -- HIGH 0개 ✓ -- 테스트 1328/1328 통과 ✓ -- Clippy clean ✓ -- Fmt clean ✓ -- 설계 결정 D1~D4 구현 정확 ✓ -- R1 리뷰 반영 완료 (Action::SwitchCloud dead code 제거) ✓ - -**선택적 후속 작업**: -- **스타일 S1** (struct vs tuple variant): application-design.md D2 결정 재확인 — struct variant 유지 결정. 현 상태 OK. -- **스타일 S2** (tracing warn span 외부): 수정 간단, 1줄 이동. 선택. -- **제안 P1** (Vec 재할당 중복): 별도 perf BL 권장. -- **제안 R1** (let-chain 평탄화): `SwitchStateMachine::committed_target()` helper 추가. 선택. -- **제안 I1** (CloudOnly → ByCloud rename): breaking하지 않으나 네이밍 논의 필요. 선택. - -**권고**: 현 상태로 PR 생성 가능. S2와 R1은 커밋 정리 전 반영하면 품질 상향 (모두 1~5줄 변경). - ---- - -## Review Mode Details - -- **Agent A** (Correctness): LOW 3건, HIGH/MED 0건 -- **Agent B** (Style): MED 1건 (S1), LOW 8건 -- **Agent C** (Suggestions): 8개 제안, 3개 권장 (P1/R1/R2) -- **병합 규칙**: 중복 제거 + 심각도 상향 채택. Agent B #1 (NotConfigured struct variant)은 기존 R1 리뷰에서도 Suggestion으로 지적됨 — application-design D2에서 struct 선택 근거 명시됨. diff --git a/devflow-docs/construction/bl-p2-074/code-plan.md b/devflow-docs/construction/bl-p2-074/code-plan.md deleted file mode 100644 index d11087b..0000000 --- a/devflow-docs/construction/bl-p2-074/code-plan.md +++ /dev/null @@ -1,231 +0,0 @@ -# Code Generation Plan: BL-P2-074 (SwitchCloud wire 완결) - -> **For agentic workers:** REQUIRED: Use `aidlc:aidlc-code-generation` with the -> "GENERATE" signal to execute this plan. Do NOT implement ad-hoc. -> `"code-generation: GENERATE — proceed with the approved plan for bl-p2-074"` - -**BL**: BL-P2-074 -**Timestamp**: 2026-04-20T08:50:00+09:00 (v1), 2026-04-20T09:10:00+09:00 (v2 — R1 리뷰 반영) -**Design source**: `devflow-docs/inception/application-design.md` (D1~D4 확정) -**Requirements source**: `devflow-docs/inception/requirements.md` (FR-1~8, NFR-1~6) -**Review response**: R1 code-plan-reviewer 결과 반영. Critical 2건 + Important 4건 + Suggestion 3건 처리. safe_display는 BL-P2-050으로 defer. - -## Files to Modify - -- [x] `src/config.rs` — `CloudConfig::default_project: Option` 필드 + backward-compat 테스트 (FR-2, NFR-2) -- [x] `src/context/types.rs` — `ContextRequest::CloudOnly { cloud }` variant (FR-2) -- [x] `src/context/error.rs` — `SwitchError::NotConfigured { cloud }` + Clone impl 확장 + 테스트 (FR-8) -- [x] `src/context/resolver.rs` — `CloudDirectory::default_project` trait 메서드 + `resolve(CloudOnly)` arm + tests 확장 (FR-3, NFR-6) -- [x] `src/context/config_cloud_directory.rs` — `default_project` production impl + 테스트 (FR-3) -- [x] `src/context/switcher.rs` — D1 idempotent 체크 (α: state() 재사용) + tests FakeClouds 확장 + `test_switcher_noop_on_same_target` + tests 헬퍼 `by_name` 주변의 `ContextRequest` 매치 노출 (C1) (FR-4, NFR-6) -- [N/A] `src/worker.rs` — `ContextRequest` 매처 arm 추가 (~line 790 영역): **변경 불필요 확인** — worker.rs에서 `ContextRequest`를 직접 매치하지 않음. 컴파일러 exhaustiveness 에러 없음. (FR-2 exhaustiveness — 자동 충족) -- [x] `src/app.rs` — `:switch-cloud` handler stub 교체 + tests FakeClouds 확장 + dispatch 테스트(+toast 미발행 assertion, I3) (FR-1, NFR-6) - -**Out of scope (BL-P2-050으로 defer)**: NFR-4의 toast `safe_display(60자 truncate + 제어문자)` 유틸 신설. `rg safe_display src/` = 0 match 확인 → 신규 유틸 결정사항 4개 (위치/truncate 문자/제어문자 정책/호출 사이트) 생성 범위가 BL-P2-074 scope를 팽창시킴. BL-P2-050 "LogPanel 텍스트 정제"와 통합 처리. - -## Tracing 이벤트 (NFR-6) -- `resolve_cloud_only` (resolver arm 진입) — 필드: `cloud`, `resolved_project` -- `switch_noop_same_target` (switcher idempotent 분기) — 필드: `cloud`, `project` -- `cloud_no_default_project` (NotConfigured 발생) — 필드: `cloud` -- toast 경로는 기존 span 상속으로 커버 (추가 이벤트 없음 — I4 문구 정정) - -## Implementation Steps (TDD) - -### Step 1: `SwitchError::NotConfigured` variant + Clone arm (I1 통합) -- [ ] RED: `src/context/error.rs::tests`에 두 테스트 추가 (동시 RED): - 1. `test_not_configured_displays_human_readable` — `SwitchError::NotConfigured { cloud: "prod".into() }.to_string() == "cloud 'prod' has no default project — use :switch-project "` - 2. `test_clone_preserves_not_configured` — `NotConfigured { cloud: "prod".into() }.clone()` match로 동일 cloud 보존 assert -- [ ] Verify RED: 컴파일 실패 (variant 부재) → 두 테스트 모두 실패. -- [ ] GREEN: - 1. enum variant 추가: `#[error("cloud '{cloud}' has no default project — use :switch-project ")] NotConfigured { cloud: String }` - 2. Clone impl (error.rs:38-57) arm 추가: `Self::NotConfigured { cloud } => Self::NotConfigured { cloud: cloud.clone() }` -- [ ] Verify GREEN: 두 신규 테스트 + 전체 회귀 통과. `cargo build --lib --tests` — SwitchError 매처가 있다면 컴파일러가 즉시 잡음 (NotConfigured arm 없음). 발견 시 각 사이트에 `_` 또는 명시적 arm 추가 (로직 변경 없이 propagate). (FR-8, NFR-5) - -### Step 2: `CloudConfig::default_project` 필드 + backward-compat + 긍정 경로 (I1 통합) -- [ ] RED: `src/config.rs::tests`에 두 테스트 추가: - 1. `test_load_clouds_yaml_without_default_project_yields_none` — 기존 valid_clouds_yaml() 로드 후 `None` assert - 2. `test_load_clouds_yaml_with_default_project_yields_some` — `default_project: my_project` 포함 YAML → `Some("my_project")` assert -- [ ] Verify RED: 컴파일 실패 (필드 부재). -- [ ] GREEN: - 1. `CloudConfig`에 `#[serde(default)] pub default_project: Option` 필드 추가 (cloud 레벨 직하, auth 블록 **밖**). - 2. `Config::cloud(&str) -> Option<&CloudConfig>` accessor가 없으면 추가 (Step 3 의존). -- [ ] Verify GREEN: 두 신규 테스트 + 기존 config 테스트 전부 통과. (FR-2, NFR-2) - -### Step 3: `CloudDirectory::default_project` trait 메서드 + 5 impl 사이트 (I1 Step 5/6 통합, S1 반영) -**S1 주의**: FakeClouds 구조가 3곳 이질적 (resolver.rs: struct with fields / switcher.rs 및 app.rs: unit struct). trait에 default 구현을 두지 **않는 설계 결정** (application-design.md L158)을 따름 → 각 FakeClouds의 구조에 맞춰 개별 확장. - -- [ ] RED: `src/context/resolver.rs::tests`에 `test_cloud_directory_default_project_reflects_config` (FakeClouds 확장 기반) 추가. `src/context/config_cloud_directory.rs::tests`에 `test_default_project_returns_configured_value` + `test_default_project_none_when_unset` 추가. 컴파일 실패 (trait 메서드 부재). -- [ ] Verify RED: 컴파일 실패. -- [ ] GREEN: - 1. `CloudDirectory` trait에 `fn default_project(&self, cloud: &str) -> Option;` 추가 (default 구현 없음). - 2. production impl: `ConfigCloudDirectory::default_project` — `self.config.cloud(cloud).and_then(|c| c.default_project.clone())`. - 3. `src/context/resolver.rs::tests::FakeClouds` — 기존 struct에 `defaults: HashMap` 필드 추가 + impl 확장. - 4. `src/context/switcher.rs::tests::FakeClouds` — unit struct면 struct with defaults로 변환, 기존 생성자 호출 사이트 업데이트. - 5. `src/app.rs::tests::FakeClouds` (~line 3444) — 동일 패턴. -- [ ] Verify GREEN: 3 FakeClouds + ConfigCloudDirectory + trait = 5 impl 사이트 컴파일 통과. 전체 테스트 회귀 없음. (FR-3, NFR-2, S1) - -### Step 4: `ContextRequest::CloudOnly` variant + exhaustiveness placeholder (C1 반영) -- [ ] RED: `src/context/types.rs::tests`에 `test_context_request_cloud_only_is_constructible` 추가. 컴파일 실패 (variant 부재). -- [ ] Verify RED: 컴파일 실패. -- [ ] GREEN: - 1. `ContextRequest` enum에 `CloudOnly { cloud: String }` variant 추가. - 2. 매처 exhaustiveness 에러 발생 → **모든 사이트에 placeholder arm 추가** (컴파일만 확보): - - `src/context/resolver.rs::resolve` — `ContextRequest::CloudOnly { .. } => Err(SwitchError::Unsupported("CloudOnly — pending Step 5".into()))` (Step 5에서 교체) - - `src/context/switcher.rs:321` **(C1 반영)** — switcher tests 영역에서 `ContextRequest::ByName {..}` 매치 존재. CloudOnly arm placeholder 추가 또는 `_` wildcard 사용. **선호: 명시적 arm**으로 Step 5 교체 지점 명확화. - - `src/worker.rs` (~line 790) — `ContextRequest` 매처 존재 시 placeholder arm 추가. - - `src/app.rs` — `ContextRequest` 매처 없음 (SwitchCloud는 `Command` 매처). 변경 없음. -- [ ] Verify GREEN: 전체 컴파일 통과 + 테스트 회귀 없음. (FR-2) -- [ ] REFACTOR: placeholder arm은 Step 5 이후 최종 로직으로 교체. - -### Step 5: `ContextTargetResolver::resolve(CloudOnly)` + 실패 경로 3종 (S3 — 헬퍼 분리 기본값) -**S3 반영**: async_trait 재귀 호출의 BoxFuture 타입 위험 회피 — `resolve_by_name_inner(&self, cloud, project, domain)` 헬퍼를 **기본값**으로 추출. `resolve` 매칭은 이 헬퍼에 위임. - -- [ ] RED: `src/context/resolver.rs::tests`에 네 테스트 추가: - 1. `test_resolve_cloud_only_returns_default_project_target` — 성공 경로 - 2. `test_resolve_cloud_only_unknown_cloud_returns_not_found` — 실패 #1 - 3. `test_resolve_cloud_only_no_default_returns_not_configured` — 실패 #2 - 4. `test_resolve_cloud_only_stale_default_returns_not_found` — 실패 #3 (default 설정, keystone empty list) -- [ ] Verify RED: Step 4 placeholder 때문에 Unsupported 반환 → 실패. -- [ ] GREEN: - 1. `resolve_by_name_inner(cloud: String, project: String, domain: Option)` 헬퍼 추출 (기존 ByName arm 본체 이동). - 2. `ByName` arm: `self.resolve_by_name_inner(cloud.unwrap_or(...), project, domain).await`. - 3. `CloudOnly` arm: - ```rust - ContextRequest::CloudOnly { cloud } => { - self.validate_cloud(&cloud)?; - let project = self.clouds.default_project(&cloud) - .ok_or(SwitchError::NotConfigured { cloud: cloud.clone() })?; - let span = tracing::info_span!("resolve_cloud_only", cloud = %cloud, resolved_project = %project); - let _enter = span.enter(); - self.resolve_by_name_inner(cloud, project, None).await - } - ``` - 4. `NotConfigured` 발생 경로에 `tracing::warn!("cloud_no_default_project", cloud = %cloud)`. -- [ ] Verify GREEN: 네 테스트 + 기존 resolver 테스트 회귀 없음. (FR-3, FR-5, FR-8, NFR-6, S3) -- [ ] REFACTOR: Step 4 placeholder arm 제거 (resolver는 final, switcher/worker는 여전히 placeholder OK — logic상 도달 불가). - -### Step 6: `ContextSwitcher::switch` idempotent 체크 (D1 α, C2 정정) -**C2 반영**: `SwitchStateView::Idle { current: Option }` — `current`가 `Option`. 올바른 패턴은 `if let SwitchStateView::Idle { current: Some(snap) } = self.state.state() && snap.target == target`. - -- [ ] RED: `src/context/switcher.rs::tests`에 두 테스트 추가: - 1. `test_switcher_noop_on_same_target` — initial target A commit 후 동일 target으로 switch → `Ok((epoch_before, snapshot_A))` 반환 + `state.epoch().current()` 첫 호출 이후 불변 + FakeSessionPort의 `begin_call_count == 1` (카운터 필드 추가 필요). - 2. `test_switch_back_after_cloud_only_returns_previous_target` (Step 12 병합, D4 검증) — FakeSessionPort 재활용 시나리오 -- [ ] Verify RED: 현재 구현은 resolver 이후 `try_begin` → `rollback`으로 epoch bump → 실패. -- [ ] GREEN: `switch()` 내부 resolver 성공 직후 분기 추가 - ```rust - if let SwitchStateView::Idle { current: Some(snap) } = self.state.state() - && snap.target == target - { - tracing::debug!("switch_noop_same_target", cloud = %target.cloud, project = %target.project_name); - return Ok((snap.epoch, snap)); - } - ``` - `state.state()`의 반환 타입이 이미 `SwitchStateView`(Clone)인지 확인. `snap.epoch`의 타입 (`Epoch`) 확인. -- [ ] Verify GREEN: 두 테스트 + 기존 switcher 테스트 회귀 없음. (FR-4, D1, D4, NFR-6) -- [ ] REFACTOR: TOCTOU 주의 주석 추가 (application-design.md D1 참조). `run_switch_to` helper가 이미 있다면 분리 요구 없음. - -### Step 7: `App::execute_command` SwitchCloud handler 교체 (I3 반영) -- [ ] RED: `src/app.rs::tests`에 `test_command_bar_switch_cloud_dispatches_context_request_without_toast` 추가 (I3 반영: dispatch emit 1회 + **toast 미발행** 확인). 기존 `test_command_bar_switch_cloud_emits_info_toast`는 **의도 변경** 방식으로 교체 (삭제보다 안전). -- [ ] Verify RED: 현재 구현은 toast 발행 → dispatch assertion 실패 + toast 미발행 assertion 실패. -- [ ] GREEN: `src/app.rs:1771-1780` stub 교체 - ```rust - Command::SwitchCloud(name) => { - self.dispatch_action(Action::SwitchContext( - crate::context::ContextRequest::CloudOnly { cloud: name }, - )); - } - ``` - Help toast(src/app.rs:1758)의 `:switch-cloud ` 문구는 유지. -- [ ] Verify GREEN: 신규 테스트 통과, 전체 회귀 없음. dispatch 1회 + toast 미발행. (FR-1, I3) - -### Step 8: 전체 회귀 + CI 게이트 (S2 — Step 13 흡수) -- [ ] GREEN: 아래 명령 전부 통과: - - `cargo fmt --all --check` - - `cargo test --lib --tests` (기존 1314 + 신규 12~14건) - - `cargo clippy --lib --tests -- -D warnings` - - `cargo build --bin nexttui` -- [ ] Verify GREEN: 4개 명령 clean. Step 4의 placeholder arm이 완전히 제거됐는지 (switcher/worker가 `SwitchError::Unsupported("...pending...")`를 반환하는 코드가 남아있지 않은지) grep 검증. (NFR-2, NFR-5) - -## Test Strategy - -| 테스트명 | 위치 | 검증 내용 | 연결 요구사항 | -|---|---|---|---| -| test_not_configured_displays_human_readable | error.rs | NotConfigured Display | FR-8 | -| test_clone_preserves_not_configured | error.rs | Clone arm 보존 | FR-8 | -| test_load_clouds_yaml_without_default_project_yields_none | config.rs | serde default backward-compat | FR-2, NFR-2 | -| test_load_clouds_yaml_with_default_project_yields_some | config.rs | 필드 역직렬화 | FR-2 | -| test_cloud_directory_default_project_reflects_config | resolver.rs (tests) | trait 메서드 | FR-3 | -| test_default_project_returns_configured_value | config_cloud_directory.rs | production impl | FR-3 | -| test_default_project_none_when_unset | config_cloud_directory.rs | 미설정 케이스 | FR-3 | -| test_context_request_cloud_only_is_constructible | types.rs | variant 생성 | FR-2 | -| test_resolve_cloud_only_returns_default_project_target | resolver.rs | 성공 경로 | FR-3 | -| test_resolve_cloud_only_unknown_cloud_returns_not_found | resolver.rs | 실패 unknown cloud | FR-5 | -| test_resolve_cloud_only_no_default_returns_not_configured | resolver.rs | 실패 no default | FR-5, FR-8 | -| test_resolve_cloud_only_stale_default_returns_not_found | resolver.rs | 실패 stale | FR-5 | -| test_switcher_noop_on_same_target | switcher.rs | idempotent epoch 불변 | FR-4, D1 | -| test_switch_back_after_cloud_only_returns_previous_target | switcher.rs | post-CloudOnly rollback | D4 | -| test_command_bar_switch_cloud_dispatches_context_request_without_toast | app.rs | dispatch 1회 + toast 없음 | FR-1, I3 | - -**기존 테스트 변경**: -- `test_command_bar_switch_cloud_emits_info_toast` (app.rs:2409) → 신규 테스트로 의도 변경 교체 (삭제 아님). - -**총 신규/변경 테스트**: 15건 (신규 15 + 기존 1 의도 변경). - -## Verification Contract - -### 완료 조건 (FR/NFR 번호 병기, I4 반영) -- [ ] `:switch-cloud ` 입력 시 `Action::SwitchContext(ContextRequest::CloudOnly { cloud })` dispatch 됨 **(FR-1)** -- [ ] `ContextRequest::CloudOnly` variant 존재 + 4 매처 사이트(resolver/switcher/worker/app) 컴파일 통과 **(FR-2)** -- [ ] `CloudConfig::default_project`가 clouds.yaml cloud 레벨에서 선택적으로 설정 가능, `#[serde(default)]`로 기존 YAML 100% backward compat **(FR-2, NFR-2)** -- [ ] `ContextTargetResolver`가 CloudOnly request를 default_project로 위임 (ByName 경로 재사용) **(FR-3)** -- [ ] `:switch-cloud ` 재입력 시 state_machine transition 카운터 불변 (epoch 동일, 순차 호출 한정) **(FR-4, D1)** -- [ ] unknown cloud / no default / stale default 3종 실패 경로 명확한 에러 variant 반환 + 적절한 toast 발행 **(FR-5)** -- [ ] Legacy `:ctx` 명령은 이번 BL에서 변경 없이 toast-only 유지 **(FR-6)** -- [ ] 신규/변경 15개 테스트 전부 통과, 기존 1314 테스트 회귀 0건 **(FR-7, NFR-2)** -- [ ] `SwitchError::NotConfigured { cloud }` 전용 variant + Clone arm **(FR-8)** -- [ ] `#[non_exhaustive]` 없는 `ContextRequest` / `SwitchError` 매처 컴파일 강제로 사이트 누락 방지 **(NFR-5)** -- [ ] tracing 이벤트 3종 (`resolve_cloud_only`, `switch_noop_same_target`, `cloud_no_default_project`) + toast 경로 기존 span 상속 **(NFR-6)** -- [ ] clippy `-D warnings` clean **(NFR-5)** -- [ ] 동시성 격리 (NFR-1) 기존 invariant 유지 — CloudOnly는 기존 epoch/state_machine 경로 재사용, 추가 spawn 없음 - -### Out of scope (BL-P2-050으로 defer) -- **NFR-4 toast `safe_display(60자)` 적용**: src/에 `safe_display` 유틸 부재. 신규 유틸 결정사항 4개 생성 필요 → BL-P2-050 "LogPanel 텍스트 정제"와 통합 처리. 이 BL에서는 cloud name 파서 레벨 sanitization에 의존 (기존 동작). - -### 검증 명령 -- `cargo test --lib --tests` — 전체 단위 + 통합 테스트 -- `cargo clippy --lib --tests -- -D warnings` — clippy 게이트 -- `cargo fmt --all --check` — 포맷 체크 -- `cargo build --bin nexttui` — 바이너리 빌드 - -### 리스크 태그 -- [x] `ContextRequest`/`SwitchError` enum 확장 — 매처 5+4 사이트 컴파일러 강제 (risk 낮음) -- [x] clouds.yaml 스키마 확장 — `#[serde(default)]`로 backward compat (risk 낮음) -- [ ] auth/security — 해당 없음 (신규 외부 입력 없음; safe_display는 BL-P2-050으로 defer하되 원본 파서 sanitization 유지) -- [ ] DB schema change — 해당 없음 - -## Status (완료 요약) - -**2026-04-20T09:50 — GENERATE 완료 / 10:10 — R1 review 반영 (dead code 제거)** -- 모든 Step GREEN (1~8) — TDD RED→GREEN→REFACTOR 준수. -- **Tests**: 1314 (baseline) → **1328** (+14 신규, 기존 1 대체). -- **검증 명령 전부 통과**: - - `cargo fmt --all --check` ✓ - - `cargo test --lib --tests` ✓ (1328/1328) - - `cargo clippy --lib --tests -- -D warnings` ✓ - - `cargo build --bin nexttui` ✓ -- **Stub 잔존 확인**: `grep "pending Step\|not available yet — use :switch-project" src/` → 0 매치 (doc 주석 제외). -- **FR/NFR coverage**: 7개 변경 컴포넌트 + 5 `CloudDirectory` impl 사이트 업데이트 + 4 매처 사이트 중 1개(worker.rs)는 컴파일러 불필요 확인 + 3 tracing 이벤트 추가 + D1 idempotent 설계 α 채택. -- **R1 리뷰 반영 (10:10)**: dead code 제거 — `Action::SwitchCloud(String)` enum variant(action.rs:206), test 참조(action.rs:269), worker stub arm(worker.rs:790-794) 3곳 삭제. caller migration 완료로 stub 주석 전제 해소. CI gate 재통과 확인(1328/fmt/clippy/bin). - -## Change Log -- 2026-04-20T08:50 v1 — 초안 작성 (15 Steps) -- 2026-04-20T09:10 v2 — R1 리뷰 반영: - - C1: Step 4 placeholder arm 목록에 `src/context/switcher.rs:321` 추가 - - C2: Step 6 idempotent 패턴을 `Idle { current: Some(snap) }`로 정정 - - I1: Step 1-2, 3-4, 5-6 병합 → 8 Steps로 축소 - - I2: Step 14 safe_display → BL-P2-050으로 defer - - I3: Step 7에 toast 미발행 assertion + 테스트 의도 변경 교체 (삭제 아님) - - I4: Verification Contract에 FR/NFR 번호 병기, tracing 3종 + 상속 문구 정정 - - S1: Step 3 FakeClouds 이질성 주의 추가 - - S2: Step 13을 Step 8(통합 게이트)에 흡수 - - S3: Step 5 async 재귀 회피를 위한 `resolve_by_name_inner` 헬퍼 기본값 diff --git a/devflow-docs/construction/bl-p2-085/code-plan.md b/devflow-docs/construction/bl-p2-085/code-plan.md new file mode 100644 index 0000000..da3bd32 --- /dev/null +++ b/devflow-docs/construction/bl-p2-085/code-plan.md @@ -0,0 +1,503 @@ +# Code Generation Plan: BL-P2-085 Cross-project scoping atomic fix + +> **For agentic workers:** REQUIRED: Use `aidlc:aidlc-code-generation` with the "GENERATE" signal to execute this plan. Do NOT implement ad-hoc. +> `"code-generation: GENERATE — proceed with the approved plan for BL-P2-085"` + +**Complexity**: Standard +**Approach**: A안 Atomic Security Fix (단일 unit, 단일 PR) +**Baseline**: 1370 tests green (c4590ab) +**Branch**: feature/bl-p2-085-cross-project-scoping + +## 결정 확정 (RED 진입 전) + +| 미결 항목 | 확정 내용 | 근거 | +|----------|----------|------| +| `FormSelectedIdValidator` 위치 | `src/module/common/scope_validator.rs` (신규 `common/` 모듈 생성) | 모듈별 중복 회피, `src/module/` 하위 규약 | +| `ScopeProvider` 1차 소스 | **`RbacGuard::project_id()` (cached copy)** | `ActionSender::send()` sync 유지. AuthProvider는 async → sync API 깨질 위험. RbacGuard는 Token 업데이트 이벤트로 동기화 | +| `update_roles_preserve_project` 인터페이스 | 신규 메서드 추가 (기존 `update_roles` 유지). `update_roles_preserve_project(&self, roles: Vec)` — project_id 미변경 | preserve API가 callsite에서 의도 명시적. enum 대비 간결 | + +## Files to Create + +- [ ] `src/infra/cross_project_guard.rs` — `CrossProjectGuard` pure fn + `CrossProjectReason` / `GuardLayer` / `GuardDecision` enum +- [ ] `src/infra/cross_project_audit.rs` — `CrossProjectBlockEvent` 빌더 + v1 canonical fingerprint (RED-time 발견: 기존 `src/infra/audit.rs::AuditLogger` 재사용. 서브디렉토리 폐기, 단일 파일로 축소) +- [ ] `src/ui/cross_project_toast.rs` — 공통 Warning-level 토스트 빌더 +- [ ] `src/module/common/mod.rs` — common 서브모듈 entry +- [ ] `src/module/common/scope_validator.rs` — `FormSelectedIdValidator` + +### RED-time 발견 (2026-04-27) + +#### A. 기존 AuditLogger (production-ready, 적극 사용 중) + +`src/infra/audit.rs`에 `AuditLogger` 존재 (file rotation 10MB×5, sensitive masking, 2-phase logging). `src/app.rs:14, 80, 98, 126, 143, 171, 203, 761-770, 782-788`에서 적극 사용. 본 BL의 `CrossProjectBlockEvent`는 신규 audit 인프라가 아니라 **AuditLogger 위에서 동작하는 specialized AuditEntry 빌더**로 재정의: +- `CrossProjectBlockEvent::to_audit_entry() -> AuditEntry` — 변환 함수 +- `cross_project_audit::emit(event, logger: &AuditLogger)` — wraps `logger.log_entry()` +- v1 canonical fingerprint, guard_layer, correlation_id, asserted_origin/target 등은 `AuditEntry::details: Option` 안에 packed +- `result = AuditResult::Failed("cross_project_block:{reason}")` 형식 +- mask_sensitive 자동 적용 (audit.rs 기존 기능) +- 결과: 기존 audit 파일(`~/Library/Caches/nexttui/audit.log`)에 동일 line 형식으로 합류 → grep/감사 도구 일관성 ↑ + +#### B. all_tenants 인프라 (광범위 사용, BL-P2-032 도입) + +`Arc` 기반 admin 전용 Ctrl+A 토글이 **이미 우리 BL의 일부 가정을 충족**: +- `src/app.rs:52, 254, 528` — toggle handler +- `src/worker.rs:56-645` — 모든 list 호출에 `all_tenants` 전달 +- `src/port/types.rs:209-236` — `ServerListFilter`/`VolumeListFilter`/`NetworkListFilter`/`SecurityGroupListFilter`/`FloatingIpListFilter`/`SnapshotListFilter` 모두 `pub all_tenants: bool` 보유 +- `src/ui/header.rs, ui/theme.rs` — 활성화 시 UI 배지 + +**갱신된 정책 명시 (이전 plan 누락분)**: **all_tenants 토글은 read-side만 영향. mutation은 항상 active scope.** admin이 Ctrl+A로 의도적으로 모든 프로젝트를 보다가 mutation 시도해도 FR2 worker origin-guard로 차단. 사용자 경험: 보기는 가능하지만 수정은 불가. + +#### C. Adapter filter 적용 실태 (Neutron이 진짜 버그, Nova/Cinder는 정상) + +| Adapter | filter 처리 | 상태 | +|---------|------------|------| +| `build_server_query` (nova.rs:175) | `name`/`status`/`host`/`flavor`/`all_tenants` 모두 처리 | ✅ 정상 | +| `build_volume_query` (cinder.rs:133) | `name`/`status`/`all_tenants` 처리 | ✅ 정상 | +| `build_snapshot_query` (cinder.rs:124) | `all_tenants` 처리 | ✅ 정상 | +| **`build_security_group_query`** (neutron.rs:231) | **`_filter` IGNORE** | ❌ **버그** | +| **`build_network_query`** (neutron.rs:225) | **`_filter` IGNORE** | ❌ **버그** | +| **`build_floating_ip_query`** (neutron.rs:240) | **`_filter` IGNORE** | ❌ **버그** | + +→ **FR1 진짜 fix 대상은 Neutron 3개 빌더만**. Nova/Cinder는 이미 정상이라 변경 거의 없음 (defense-in-depth refilter만 추가). + +#### D. CrossTenantGuard + +`src/infra/cross_tenant.rs::CrossTenantGuard` (175 LoC, break-glass 모델 = 거절된 옵션 B)는 진짜 **dead code** (어떤 호출부도 없음). 이번 BL에선 무시. 후속 정리 BL에서 삭제 또는 all_tenants Arc과 통합. + +## Files to Modify + +- [x] `src/action.rs` — `DispatchedAction` struct 추가 (Action enum 옆) — Step 2 완료 +- [x] `src/infra/mod.rs` — `cross_project_guard` 서브모듈 export — Step 1 완료 +- [ ] `src/context/action_channel.rs` — `ActionSender` T 교체 (Action → DispatchedAction), `ScopeProvider` trait + send 내부 스탬핑, `ActionReceiver`도 T 교체 +- [ ] `src/context/mod.rs` — (필요 시) `ScopeProvider` re-export +- [ ] `src/worker.rs` — (1) `action_to_kind()` exhaustive 보강 + RBAC parity 테스트. (2) `run_worker` DispatchedAction 수신 + origin guard hook + AuditLogger 주입. (3) list 호출부 `tenant_id` enrichment +- [ ] `src/infra/rbac.rs` — `check_project_scope()`, `update_roles_preserve_project()` 메서드 추가 +- [ ] `src/port/types.rs` — `NetworkListFilter`/`SecurityGroupListFilter`/`FloatingIpListFilter`에 `pub tenant_id: Option` 추가 +- [ ] `src/adapter/http/neutron.rs` — **`_filter` IGNORE 패턴 fix** (build_network_query/build_security_group_query/build_floating_ip_query 본체 수정) + impl 내부 refilter wiring + audit_logger 필드 추가 +- [ ] `src/adapter/http/nova.rs` — refilter wiring + audit_logger 필드 추가 (query는 이미 정상) +- [ ] `src/adapter/http/cinder.rs` — refilter wiring + audit_logger 필드 추가 (query는 이미 정상) +- [ ] `src/module/image/mod.rs` — DeleteImage/UpdateImage 경로에 adapter pre-mutation GET 추가 +- [ ] `src/error.rs` — `AppError::CrossProjectBlocked { origin, active, reason }` variant 추가 +- [ ] `src/app.rs` — `ActionSender` 생성자에 `ScopeProvider`(RbacGuard arc) 주입 + `Arc` 어댑터/워커에 분배. 테스트 헬퍼 `FakeActionSender` 갱신 +- [ ] `src/ui/mod.rs` — `cross_project_toast` 서브모듈 export +- [ ] `src/module/mod.rs` — `common` 서브모듈 export + +## Policy Clarification (RED-time 발견 반영) + +**all_tenants 토글과 정책 A의 분업** — 새 명시: +- **Read-side**: admin이 Ctrl+A로 `all_tenants=true` 활성화 → `all_tenants=1` 쿼리 추가 → 모든 프로젝트 리소스 조회 가능. refilter SKIP. 정상 동작. +- **Read-side strict**: `all_tenants=false` (기본) → `tenant_id={active_scope}` 주입. server-side가 enforce. 추가로 client-side refilter로 defense-in-depth (서버 enforcement 누락 방어). +- **Write-side**: `all_tenants` 상태와 **무관하게** 항상 active scope만 mutation 허용. FR2 worker origin-guard가 강제. admin이 list로 cross-project를 봤어도 mutation은 차단. +- **사용자 경험**: all_tenants=true일 때 admin이 cross-project 리소스를 보고 mutation 시도 → "보기는 가능, 수정은 불가 + 토스트로 :switch-project 안내" + +이 정책은 requirements.md / application-design.md의 시맨틱과 정합하며, BL-P2-032(Ctrl+A 토글)와 자연스럽게 공존. + +## Implementation Steps + +### Phase 1 — Foundation (pure, no dependencies) + +- [x] **Step 1**: `CrossProjectGuard` 순수 함수 모듈 — 9 tests passed, total 1379 (+9) + - [x] RED+GREEN: `check_origin_scope` / `check_form_selection` / `GuardDecision` / `CrossProjectReason` (4 variants 포함 UnscopedFailSafe) / `GuardLayer` (4 variants) — `src/infra/cross_project_guard.rs` + - [x] Verify GREEN: 회귀 0 + +- [x] **Step 2**: `DispatchedAction` struct — 2 tests passed, total 1381 (+2) + - [x] RED+GREEN: struct + `stamped(action, origin)` / `unstamped(action)` / `is_stamped()` helpers — `src/action.rs` + - [x] Verify GREEN: 회귀 0 + +### Phase 2 — Error variant + +- [x] **Step 3**: `AppError::CrossProjectBlocked` — 1 test passed, total **1382** (+1) + - [x] RED: `tests::test_cross_project_blocked_error_display` in `src/error.rs` — display 메시지 포함 요소 assertion (variant 미존재 컴파일 실패 확인) + - [x] Verify RED + - [x] GREEN: `CrossProjectBlocked { reason: CrossProjectReason, guard_layer: GuardLayer }` variant + `#[error("Cross-project operation blocked: {r} (layer: {l})", r = reason.as_str(), l = guard_layer.as_str())]` + `crate::infra::cross_project_guard` import + - [x] Verify GREEN: 기존 `test_app_error_display` + 신규 테스트 + 회귀 0 + clippy `-D warnings` clean + +### Phase 3 — Audit infrastructure + +- [x] **Step 4**: `CrossProjectBlockEvent` builder → AuditLogger 통합 (RED-time 발견 반영) — 8 tests passed, total **1390** (+8). `sha2 = "0.10"` dep 추가. 5분 audit.rs 시그니처 사전 검증으로 매핑 정확도 확보. + - **위치**: `src/infra/cross_project_audit.rs` (단일 파일. 기존 `src/infra/audit.rs::AuditLogger` 재사용) + - **설계**: + ```rust + pub struct CrossProjectBlockEvent { + pub timestamp: DateTime, + pub actor_user_id: String, // Keystone UUID + pub actor_cloud: String, + pub active_project_id: Option, + pub asserted_origin_project_id: Option, + pub target_project_id: Option, + pub action_type: String, + pub resource_kind: String, + pub resource_id: Option, + pub resource_name: Option, + pub reason: CrossProjectReason, + pub guard_layer: GuardLayer, + pub correlation_id: u64, // epoch + } + + impl CrossProjectBlockEvent { + pub fn to_audit_entry(&self) -> AuditEntry { ... } // details JSON에 fingerprint, guard_layer, correlation_id, asserted_origin, target packing + pub fn fingerprint(&self) -> String { ... } // v1 canonical: "v1|" + user + "|" + active + "|" + origin + "|" + target + "|" + action_type + "|" + resource_id, sha256.hex()[..12] + } + + pub fn emit(event: &CrossProjectBlockEvent, logger: Option<&AuditLogger>) { + // logger 있으면 logger.log_entry(event.to_audit_entry()) 시도 + // 없거나 실패 시 tracing::warn! fallback (best-effort) + } + ``` + - [x] RED: + - [x] `test_event_to_audit_entry_field_mapping` + - [x] `test_audit_entry_details_contains_fingerprint_guard_layer_correlation_id` + - [x] `test_audit_entry_result_is_failed_with_reason_string` + - [x] `test_fingerprint_v1_canonical_format` — canonical 문자열 재유도 + sha256[..6] hex 비교 (impl과 독립) + - [x] `test_fingerprint_boundary_collision_free` + - [x] `test_fingerprint_none_resource_id_uses_empty` + - [x] `test_emit_with_logger_writes_audit_entry` — `tempfile::TempDir` 패턴 + - [x] `test_emit_without_logger_fallback_to_tracing` + - [x] Verify RED (4 compile errors: struct + emit fn 미존재) + - [x] GREEN: 위 설계 구현. `chrono::DateTime`, `sha2::Sha256` (신규 dep `sha2 = "0.10"`), `serde_json::Value` 활용. `write!` macro로 hex encode (clippy `unwrap_used = deny` 회피) + - [x] Verify GREEN: 회귀 0, clippy `-D warnings` clean + - [x] **Codex P2 review fix** (2026-04-27): `emit()` success branch에 `logger.rotate_if_needed()` 추가. `App::record_audit` (src/app.rs:780)의 패턴과 parity 확보 — `MAX_LOG_SIZE`(10MB)/`MAX_ROTATED_FILES`(5) 정책이 cross_project_block 폭발 시에도 작동. 회귀 0 (1390 stable, 5/5 runs). + + **사전 검증 결과** (audit.rs 5분 점검, plan과 차이): + - `AuditEntry.timestamp`: plan `DateTime` → 실제 `String` (ISO). `to_rfc3339()`로 변환. + - `AuditEntry.resource_id`: plan `Option` → 실제 `String` (NOT Option). `unwrap_or_default()` 사용. + - 필드 rename: `action_type → action`, `resource_kind → resource_type`. + - `sha2`는 plan이 "이미 deps"라고 잘못 가정 — Cargo.toml에 부재. 옵션 (a) `sha2 = "0.10"` 추가로 결정. + - `AuditLogger.log_entry(&self, entry) -> Result<()>` sync. emit best-effort 패턴 그대로 적용. + - 자동 sensitive masking 작동 — 우리 details 키는 충돌 0. + +### Phase 4 — RBAC 확장 + +- [x] **Step 5**: `RbacGuard::check_project_scope()` — 7 tests passed + - [x] RED (admin match/mismatch / unscoped fail-safe / reader create match/mismatch / member admin-only action / reader read match): 23 compile errors (enum + 2 methods 미존재) + - [x] Verify RED + - [x] GREEN: `RbacScopeDecision { Allow, Deny { reason: RbacDenialReason } }` + `RbacDenialReason { RoleTier, ProjectScope, Both } + as_str()` + `check_project_scope(target, action) -> RbacScopeDecision` (`is_some_and(|p| p == target)`로 None=fail-safe) + - [x] Verify GREEN: reason 정확히 `role_tier`/`project_scope`/`both` 반환 + +- [x] **Step 6**: `RbacGuard::update_roles_preserve_project()` — 4 tests passed (Step 5와 동일 commit) + - [x] RED: + - [x] `test_preserve_project_keeps_existing_project_id` + - [x] `test_preserve_project_updates_roles_and_effective` + - [x] `test_update_roles_vs_preserve_diff` (회귀 방지) + - [x] `test_preserve_project_clears_capabilities_parity_with_update_roles` (parity) + - [x] Verify RED + - [x] GREEN: `update_roles_preserve_project(roles)` 신규 — `self.state.write()` 잡고 `roles + effective_role` 갱신 + `capabilities.clear()` (update_roles와 parity), `project_id` 보존 + - [x] Verify GREEN: 회귀 0 (1390 → 1401, +11), clippy clean + + **사전 검증 결과** (rbac.rs 5분 점검): plan 가정 모두 정확. RbacGuard structure / RwLock / can_perform 매트릭스 / TokenRole role(name) helper 패턴 그대로 채용. + +### Phase 5 — Action 분류 exhaustive + +- [x] **Step 7**: `action_to_kind()` exhaustive + RBAC parity 테스트 (Reviewer Should-consider #1 반영) — 5 tests passed, total **1406** (+5) + - [x] RED: + - [x] `test_action_to_kind_fetch_and_nav_variants_return_none` — 28개 None variant 일괄 (Fetch*/Navigate/Back/UI/system/SwitchBack) + - [x] `test_action_to_kind_all_mutations_have_kind` — 38개 mutation variant 일괄 (param shape 사전 점검 필요했음) + - [x] `test_action_is_mutation_helper_parity` — `action_to_kind.is_some() == action_is_mutation` + - [x] `test_action_to_kind_rbac_mapping_lockstep` — 11 mutation × ActionKind 명시 매핑 (Create/Delete/ForceDelete/Resize/Migrate/Evacuate/EnableDisable/ManageQuota/Attach/Detach) + - [x] `test_action_to_kind_switch_context_returns_none` — orchestration vs mutation 구분 + - [x] Verify RED (3 errors: `action_is_mutation` 미존재. param shape mismatch 13건은 사전점검 누락분으로 즉시 수정) + - [x] GREEN: `_ => None` fallthrough 제거 + 모든 Fetch/Nav/UI/System/Context variant 명시. `pub(crate) fn action_to_kind` + `pub(crate) fn action_is_mutation(action: &Action) -> bool` 도입. `#[allow(dead_code)]` on action_is_mutation (Phase 6 Step 9에서 ActionSender가 wire) + - [x] Verify GREEN: 1406 stable, clippy `-D warnings` clean + + **컴파일타임 안전망 확보**: 새 Action variant 추가 시 `action_to_kind` 컴파일 실패 — silent miss 불가능 + +### Phase 6 — Envelope 교체 (가장 침습적) + +- [x] **Step 8**: `ScopeProvider` trait — 3 tests passed, total **1410** (+3). commit `73b347d` + - [x] RED: 3 tests in `src/context/action_channel.rs::tests` — fails to compile (ScopeProvider undeclared) + - [x] Verify RED (E0433: cannot find type `ScopeProvider`) + - [x] GREEN: `pub trait ScopeProvider: Send + Sync { fn current_project_id(&self) -> Option; }` + `impl ScopeProvider for Arc` (delegates to `RbacGuard::project_id()` which is single-snapshot atomic post-Codex P1 hotfix `b4b4c44`) + - [x] Verify GREEN — cargo test 1410 pass, clippy -D warnings clean + - [x] Bonus test `test_scope_provider_reflects_post_update_change` — locks in *live read* contract (FR2 stamping requires live state, not captured snapshot) + +- [x] **Step 9**: `ActionSender` 타입 교체 + 스탬핑 — 3 tests passed, total 1413 (+3). commit `1f80968` (Step 9+10 단일 atomic) + - [ ] RED: + - `test_sender_stamps_mutation_with_current_scope` — scope=A에서 send(CreateServer) → envelope.payload.origin_project_id == Some("A") + - `test_sender_leaves_readonly_unstamped` — send(FetchServers) → origin == None + - `test_sender_handles_unscoped_provider_returns_none_origin` + - 기존 ActionReceiver 테스트 회귀 없음 검증 + - [ ] Verify RED (컴파일 실패 포함 — tx 타입 바뀌면 app.rs 호출부 정합성 문제 → 다음 Step에서 해결) + - [ ] GREEN: + - `ActionSender { tx: mpsc::UnboundedSender>, epoch: Arc, scope_provider: Arc }` + - `send(action: Action)` 내부: mutation이면 origin=Some(scope_provider.current_project_id().unwrap_or_default()), read-only면 None → DispatchedAction wrap → VersionedEvent wrap → tx send + - `ActionReceiver`도 `VersionedEvent`으로 교체. `recv()`는 기존 호환을 위해 `Option` 유지하고 `.map(|d| d.action)` 내부 unwrap. + - **Worker 수신 경로 명시 (Reviewer Should-consider #2)**: `run_worker`는 **ActionReceiver를 쓰지 않고 raw `mpsc::UnboundedReceiver>`를 직접 소비**해야 origin_project_id에 접근 가능. ActionReceiver는 테스트 호환 목적으로만 유지. Step 11에서 worker 측 raw 소비 코드로 전환. + - [ ] Verify GREEN: 새 테스트 통과 + 기존 ActionReceiver 테스트 통과 + +- [x] **Step 10**: `app.rs` + 테스트 헬퍼 갱신 — Step 9와 단일 commit `1f80968`. main.rs RbacGuard Arc 공유, ActionReceiver 외부 API 보존하여 ~100 module 테스트 무수정 무회귀. + - [ ] RED: 컴파일 에러로 드러나는 위치들 정리. 기존 테스트가 ActionSender를 생성하는 곳마다 ScopeProvider 주입 필요 + - [ ] Verify RED (compile fail) + - [ ] GREEN: `ActionSender::new(...)` 생성자에 scope_provider 인자 추가. `App::new()` 등에서 RbacGuard arc 전달. 테스트 헬퍼 `FakeActionSender` / `FakeScopeProvider` 추가 + - [ ] Verify GREEN: `cargo test` 전체 green (1370 + 신규) + +### Phase 7 — Worker hook + +- [ ] **Step 11**: `run_worker` C5 pseudo-flow + AuditLogger 통합 (RED-time 발견 반영) — **3-cycle 분할 진행 중 (11a/11b/11c)** + - **분할 근거**: worker FR2 hook는 시그니처 +3 인자, `AppEvent` variant 신규, `CrossProjectBlockEvent::new` 편의 생성자, main wire 등 영향범위가 큼. 토큰 분산 + 외부 리뷰 주기 확보를 위해 11a→11b→11c 순차 진행. + - **Worker signature 확장 (전체 11 끝나면 도달할 모습)**: `run_worker(...)`가 `Option>` 추가 인자 (test 호환). 기존 worker는 audit_logger 부재로도 동작 가능 (best-effort). + - [x] **Step 11a (완료)** — origin guard hook 단일 레이어. signature 미변경 (rbac/dispatched로 충분). `pub(crate) fn check_dispatched_origin(&DispatchedAction, &RbacGuard) -> GuardDecision` 추가, `run_worker` recv loop에 `if Block { continue; }` 1줄 hook. audit/toast 없이 silent block. **2 RED tests** (allow_match / block_mismatch). 1413 → **1415 pass**, clippy clean. + - [x] **Step 11b (완료)** — AuditLogger 통합 + `CrossProjectBlockEvent::new` 편의 생성자 + `worker::emit_origin_block_audit` sync 헬퍼 + run_worker signature 확장 (+`audit_logger: Option>`, +`actor_cloud: String`, +`actor_user_id: String`). `App.audit_logger` Option → Option> 리팩터 + `App::audit_logger_arc()` getter 노출 (worker가 동일 인스턴스 공유 → BufWriter 인터리빙 회피). main.rs에서 username을 actor_user_id로 (App::build_audit_entry parity, Keystone UUID resolution은 follow-up). resource_kind는 빈 문자열 + TODO (Step 11c follow-up). **3 RED tests**: `test_new_convenience_constructor_fills_required_fields_and_now_timestamp`, `test_emit_origin_block_audit_writes_entry_when_logger_present`, `test_emit_origin_block_audit_does_not_panic_when_logger_none`. correlation_id = action_epoch (envelope.epoch()) — Reviewer Must-fix #1 충족. 1415 → **1418 pass**, clippy clean. + - [x] **Step 11c (완료)** — `AppEvent::CrossProjectBlocked { reason: String, action: String }` variant + `worker::make_cross_project_blocked_event` sync helper + `App::generate_toast` Error-level 매핑 ("Cross-project block: {action} ({reason})", PermissionDenied parity). run_worker Block 경로: audit emit → AppEvent send → continue (audit이 토스트보다 먼저 — 수신자 drop에도 audit 보존). **3 RED tests**: `test_worker_allows_readonly_without_guard` (unstamped DispatchedAction → Allow, RBAC project_id 유무 불문), `test_make_cross_project_blocked_event_carries_reason_and_action` (helper 변환), `test_generate_toast_for_cross_project_blocked_pushes_error` (App handle_event end-to-end). 1418 → **1421 pass** (단일 스레드; 멀티스레드 시 keystone_project_directory mock-isolation flake 1건 — BL-P2-086 후보, BL-P2-085 회귀 아님), clippy clean. + - [ ] RED (legacy — 11a/11b/11c 분리됨): + - `test_worker_allows_mutation_when_origin_matches` (11a — done) + - `test_worker_blocks_mutation_when_origin_mismatch` (11a — done. Step 11b에서 audit assertion 추가) + - `test_worker_allows_readonly_without_guard` (11c) + - `test_worker_emits_toast_before_any_api_error` (11c) + - `test_worker_block_works_without_audit_logger` (11b) + - [ ] Verify RED + - [ ] GREEN: + ```rust + // src/worker.rs:53 근처 + let (dispatched, epoch) = envelope.into_parts(); + if !ctx.epoch_matches(epoch) { continue; } + let DispatchedAction { action, origin_project_id } = dispatched; + if let Some(origin) = origin_project_id { + let current = rbac.project_id().unwrap_or_default(); + match cross_project_guard::check_origin_scope(&origin, ¤t) { + GuardDecision::Allow => proceed_dispatch(action).await, + GuardDecision::Block { reason } => { + let event = CrossProjectBlockEvent::new( + reason.clone(), + GuardLayer::Fr2Worker, + &action, + &token_info, + epoch, + ); + cross_project_audit::emit(&event, audit_logger.as_deref()); + toast_tx.send(CrossProjectToast::build_origin_mismatch(&active_name, &origin_name)).await; + continue; + } + } + } else { + proceed_dispatch(action).await; // read-only + } + ``` + - [ ] Verify GREEN + +### Phase 8 — Adapter FR1 + +- [ ] **Step 12**: Neutron query builder fix (`_filter` IGNORE 패턴 제거) — RED-time 발견 반영 + - **버그 위치 실측 확정**: `src/adapter/http/neutron.rs:225 build_network_query`, `:231 build_security_group_query`, `:240 build_floating_ip_query` — 모두 `_filter` 접두사로 무시 중. Nova/Cinder는 이미 정상이라 제외. + - **Filter struct 확장**: `NetworkListFilter`/`SecurityGroupListFilter`/`FloatingIpListFilter`(`src/port/types.rs:233-`)에 `pub tenant_id: Option` 추가. 기존 `all_tenants: bool`은 유지. + - **Worker enrichment**: `src/worker.rs`의 list 호출부에서 token에서 active project_id 추출하여 filter에 채워 넣음 (`tenant_id: Some(rbac.project_id().unwrap_or_default())`) + - **Subnet 처리**: `list_subnets(network_id)`은 device 범위 내포라 변경 없음 (Q1 매트릭스 결정) + - [ ] RED: + - `test_build_security_group_query_injects_tenant_id_when_all_tenants_false` + - `test_build_security_group_query_uses_all_tenants_1_when_true_skips_tenant_id` + - `test_build_security_group_query_no_op_when_no_tenant_id_no_all_tenants` (fail-safe — 둘 다 없으면 query 비워둠) + - `test_build_network_query_injects_tenant_id_when_all_tenants_false` + - `test_build_network_query_all_tenants_true_branch` + - `test_build_floating_ip_query_injects_tenant_id_when_all_tenants_false` + - `test_build_floating_ip_query_all_tenants_true_branch` + - 각 테스트: `query` 문자열에 `tenant_id={scope}` 포함 또는 `all_tenants=1` 포함 + pagination 유지 + - [ ] Verify RED + - [ ] GREEN: + ```rust + // src/adapter/http/neutron.rs:231 (예시 — security_group) + fn build_security_group_query( + filter: &SecurityGroupListFilter, + pagination: &PaginationParams, + ) -> String { + let mut parts = Vec::new(); + if filter.all_tenants { + parts.push("all_tenants=1".to_string()); + } else if let Some(ref tid) = filter.tenant_id { + parts.push(format!("tenant_id={}", encode_param(tid))); + } + append_pagination_parts(&mut parts, pagination); + parts.join("&") + } + ``` + 동일 패턴으로 build_network_query, build_floating_ip_query. + - [ ] Verify GREEN: 신규 7-8 tests + 회귀 0 + +- [ ] **Step 13**: Adapter response refilter + adapter_filter audit event (Reviewer Must-fix #2 + RED-time 발견) + - **policy 명시**: `all_tenants=true`(admin이 의도적으로 토글)인 경우 refilter SKIP. `all_tenants=false`인데 응답에 cross-project 섞이면 → drop + AdapterFilterViolation event (서버측 enforcement 실패 보호 = defense-in-depth) + - **공통 pure fn 위치**: `src/adapter/http/scope_refilter.rs` 신규 (`src/adapter/http/mod.rs` export) + - [ ] RED: + - `test_refilter_drops_cross_project_items_when_scope_strict` — `active=A`, `all_tenants=false`, items mixed → cross-project 항목 드롭, dropped Vec 반환 + - `test_refilter_keeps_all_when_all_tenants_true` — `all_tenants=true` → no-op, 모든 항목 유지 + - `test_refilter_keeps_active_scope_items` + - **`test_refilter_emits_cross_project_block_event_with_adapter_filter_reason`** — dropped > 0 시 각 드롭 항목마다 `CrossProjectBlockEvent`(reason=`AdapterFilterViolation { resource_id, project_id }`, guard_layer=`Fr1Adapter`) 1건 emit + - `test_refilter_no_emit_when_strict_and_no_drops` + - [ ] Verify RED + - [ ] GREEN: + ```rust + // src/adapter/http/scope_refilter.rs + pub trait HasTenantId { + fn tenant_id(&self) -> Option<&str>; + fn resource_id(&self) -> Option<&str>; + } + + pub fn refilter_by_scope( + items: Vec, + active: Option<&str>, + all_tenants: bool, + ) -> (Vec, Vec) { + if all_tenants { return (items, Vec::new()); } + let Some(active) = active else { return (items, Vec::new()); }; + let mut kept = Vec::new(); + let mut dropped = Vec::new(); + for item in items { + match item.tenant_id() { + Some(tid) if tid == active => kept.push(item), + _ => dropped.push(item), + } + } + (kept, dropped) + } + ``` + - `HasTenantId` impl for **SecurityGroup / Network / FloatingIp** (Neutron Step 13) + **Server (Nova) / Volume / Snapshot (Cinder)** (Step 14) + - list_* 내부 wiring: paginated_list 결과 → refilter_by_scope 적용 → dropped iterate → `CrossProjectBlockEvent::new(AdapterFilterViolation { resource_id, project_id }, GuardLayer::Fr1Adapter, ...)` + `cross_project_audit::emit(&event, logger)` (각 항목별) + - **AuditLogger 접근**: NeutronHttpAdapter에 `audit_logger: Option>` 필드 추가 (생성자 변경) — App에서 주입 + - [ ] Verify GREEN + +- [ ] **Step 14**: Nova/Cinder defense-in-depth refilter (이미 query 정상이라 query 수정 불필요 — RED-time 발견 반영) + - **상태 확정**: `build_server_query` (nova.rs:175), `build_volume_query` (cinder.rs:133), `build_snapshot_query` (cinder.rs:124) 모두 이미 `all_tenants` flag 정상 처리. **추가 query 변경 불필요**. + - **이번 step 범위**: defense-in-depth로 **응답측 refilter만 추가** + `HasTenantId` trait impl 추가 + - [ ] RED: + - `test_nova_server_has_tenant_id_impl_extracts_field` + - `test_cinder_volume_has_tenant_id_impl_extracts_project_id_field` (Cinder는 `project_id`/`tenant_id` 필드명이 둘 다 사용됨, models 확인) + - `test_cinder_snapshot_has_tenant_id_impl` + - `test_nova_list_servers_refilter_drops_cross_project_when_strict` — `all_tenants=false`에서 mock 응답에 cross-project Server 섞어 → drop + AdapterFilterViolation event 1건 + - `test_cinder_list_volumes_refilter_drops_cross_project_when_strict` + - [ ] Verify RED + - [ ] GREEN: + - `HasTenantId` impl for `Server`, `Volume`, `Snapshot` (models 확인 후 정확 필드명 매핑) + - `list_servers`, `list_volumes`, `list_snapshots` impl 내부에 `refilter_by_scope` 호출 + AdapterFilterViolation event emit (Step 13의 wiring 패턴 재사용) + - NovaHttpAdapter, CinderHttpAdapter에 `audit_logger: Option>` 필드 추가 + - [ ] Verify GREEN + +### Phase 9 — Form validation FR4 + +- [ ] **Step 15**: `FormSelectedIdValidator` 공통 헬퍼 + - [ ] RED: + - `test_validate_single_selection_match_passes` + - `test_validate_single_selection_mismatch_returns_error` + - `test_validate_multi_selection_first_mismatch_wins` + - `test_validation_error_carries_field_name_and_reason` + - [ ] Verify RED + - [ ] GREEN: `src/module/common/scope_validator.rs` 생성. `pub struct FormValidationError { field, reason: CrossProjectReason }` + `pub fn validate_form_scope<'a>(active, selections) -> Result<(), FormValidationError>` + - [ ] Verify GREEN + +- [ ] **Step 16**: Glance DeleteImage/UpdateImage pre-mutation GET (Reviewer Must-fix #3 확인: 어댑터 surface 실측) + - **Adapter surface 실측 (plan-time)**: ✅ 확인됨 + - `src/adapter/http/glance.rs:115 async fn get_image(&self, image_id) -> ApiResult` 존재 + - `src/models/glance.rs:20 pub owner: Option` 존재 → project_id 매핑 + - [ ] RED: + - `test_image_delete_rejects_cross_project_owner` — mock GET returns image with owner=B, active=A → form submit 거부 + toast + - `test_image_delete_allows_same_project_owner` + - `test_image_update_rejects_cross_project` + - `test_image_delete_with_missing_owner_fail_safe` — owner=None → deny (fail-safe) + - `test_image_delete_emits_cross_project_block_event_with_fr4_form_layer` + - [ ] Verify RED + - [ ] GREEN: `src/module/image/mod.rs`의 Delete/Update 경로에서 submit 직전 `self.adapter.get_image(id).await` → `image.owner` 추출 → `CrossProjectGuard::check_form_selection(owner, active)` 호출 → Block이면 event emit(`GuardLayer::Fr4Form`) + reject + toast + - [ ] Verify GREEN + +### Phase 10 — UI FR6 + +- [ ] **Step 17**: `CrossProjectToast` 공통 카피 + - [ ] RED: + - `test_origin_mismatch_toast_contains_both_project_names` + - `test_glance_owner_mismatch_toast_contains_owner_name` + - `test_form_mismatch_toast_contains_field_label` + - `test_toast_level_is_warning` + - `test_toast_respects_safe_display_60char_truncate` + - [ ] Verify RED + - [ ] GREEN: `src/ui/cross_project_toast.rs` 생성. 3 builder 함수. `ToastLevel::Warning`, `safe_display(name, 60)` 적용 + - [ ] Verify GREEN + +### Phase 11 — Background Task Audit (Reviewer Must-fix #4) + +- [ ] **Step 18**: Background task action emit 경로 scope 검증 + - 대상: `src/worker.rs` 내 `poll_migration_progress`, `poll_volume_attachment`, `poll_server_status` 등 background task가 `ActionSender::send()`를 호출하는 경로. + - 우려: 중앙 스탬핑(Step 9)이 ActionSender 레벨에서 동작하므로 background task가 **동일 ActionSender를 clone해서 사용한다면** 자동으로 current scope 스탬프. 그러나 **background task가 spawn 시점의 Arc 복제만 보유하고, scope 전환 후에도 "과거 scope 문맥에서 발생한 작업을 과거 scope로 stamp하려는" 의도라면 갈등 가능**. + - [ ] RED: + - `test_background_poll_emits_action_with_current_scope_stamp` — background task가 emit한 Action이 **현재 시점** active scope로 스탬프되는지 assertion (의도: 사용자가 이미 B로 전환했으면 background도 B 기준으로 차단 판단) + - `test_background_poll_stale_action_blocked_after_scope_switch` — poll_server_status가 오래 걸려서 완료된 시점에 scope=B면 origin=A로 스탬프되어 차단 + - [ ] Verify RED + - [ ] GREEN: 대부분 기존 ActionSender 재사용으로 충족. 별도 코드 변경은 일반적으로 불필요 — **단, spawn 시 ActionSender clone이 같은 scope_provider arc를 공유함을 주석으로 명시**. 필요 시 worker.rs의 polling 함수에 DocComment 추가 + - [ ] Verify GREEN + +## Test Strategy + +| Test file | 주 검증 | 총 테스트 수 (예상) | +|-----------|---------|-------------------| +| `src/infra/cross_project_guard.rs::tests` | 순수 가드 의사결정 매트릭스 | ~8 | +| `src/action.rs::tests` (DispatchedAction 부분) | 엔벨로프 구조 | 3 | +| `src/error.rs::tests` | CrossProjectBlocked 메시지 | 1 | +| `src/infra/audit/cross_project_block.rs::tests` | 스키마 + fingerprint canonicalization | 5-6 | +| `src/infra/rbac.rs::tests` | scope matrix + preserve | 6-8 | +| `src/worker.rs::tests` (action_to_kind 보강 + RBAC parity) | 전 variant parity + RBAC lockstep | 6-8 | +| `src/context/action_channel.rs::tests` | ScopeProvider + 스탬핑 | 5 | +| `src/worker.rs::tests` (guard hook + background poll) | allow/block/order + poll stamp | 6 | +| `src/adapter/http/neutron.rs::tests` | query + refilter + adapter_filter event | 9-10 | +| `src/adapter/http/nova.rs::tests`, `cinder.rs::tests` | all_tenants 정책 + refilter T impl | 6-8 | +| `src/module/common/scope_validator.rs::tests` | form validation | 4 | +| `src/module/image/mod.rs::tests` (Glance pre-check) | pre-mutation GET + reject + fail-safe + event | 5 | +| `src/ui/cross_project_toast.rs::tests` | 카피 + safe_display | 5 | +| **합계** | — | **~65 신규 테스트** | + +**Plan-level 명시적 defer**: synthesis Should-consider #3 "e2e mock integration test merge-blocking" 은 mock HTTP 서버 도입 없이는 기존 패턴 안에서 e2e 재현이 어려움. 이번 BL에선 **각 계층 단위 테스트 조합 + adapter_filter 이벤트 end-to-end trace**로 cover. 전면 e2e는 BL-P2-081에서 자연스럽게 도입. + +## Verification Contract + +### 완료 조건 +- [ ] 6 FR 모두 해당 수용기준 충족 (requirements.md 참조) +- [ ] Must-fix 8건, Should-consider 5건 반영 확인 (synthesis.md 매핑) +- [ ] 신규 테스트 ~60건 전부 green +- [ ] 기존 1370 tests 회귀 없음 → 총 **~1430 tests green** +- [ ] Clippy `-D warnings` 통과 (unwrap/expect/enum_glob_use deny 유지) +- [ ] `cargo fmt --check` 통과 +- [ ] DispatchedAction + ActionSender 중앙 스탬핑으로 구현 (55 call site 수정 없음) + +### 검증 명령 +- `cargo test --lib` — 전체 라이브러리 테스트 +- `cargo test --test devstack_directory` — integration test (BL-P2-080 placeholder) +- `cargo clippy --all-targets -- -D warnings` — lint +- `cargo fmt --check` — 포맷 +- `grep -rn "not yet implemented\|todo!()\|unimplemented!()" src/` — 신규 stub 도입 없음 확인 + +### 리스크 태그 +- [x] **auth/security** — P0 권한 경계 변경 (이 BL의 본질) +- [ ] DB schema change — 해당 없음 (로컬 파일 로그만 변경) + +### 예상 diff 규모 +- 신규 파일: 6 (cross_project_guard, audit/mod, audit/cross_project_block, cross_project_toast, module/common/mod, module/common/scope_validator) +- 수정 파일: ~14 (action, action_channel, worker, rbac, infra/mod, neutron, nova, cinder, image/mod, error, app, ui/mod, module/mod, context/mod) +- LoC 추가: 추정 ~1200-1600 lines (테스트 포함) + +## 기존 테스트 회귀 위험 지점 + +1. **ActionSender 타입 교체 (Step 9-10)**: `ActionSender::new` 시그니처 변경 → 모든 테스트 헬퍼의 생성자 호출부 영향. 대책: `FakeScopeProvider` 테스트 유틸 제공, 테스트별 유지보수 +2. **action_to_kind exhaustive (Step 7)**: `_ => None` 제거 → 신규 Action variant가 컴파일 차단 (의도된 효과, 그러나 PR#82 직후 머지된 신규 variant와 충돌 가능성 낮음) +3. **Neutron response refilter (Step 13)**: 기존 list 테스트가 "response에 포함된 모든 항목"을 검증하면 깨짐. 대책: mock 응답을 active_scope와 일치하도록 조정하거나 테스트가 refilter 통과 후 기대값 assertion +4. **Image module GET 호출 추가 (Step 16)**: mock adapter가 `get_image`를 제공해야 함. 기존 mock에 없으면 신규 추가 필요 + +## TDD 위반 방지 체크 +- [ ] 각 Step별 "RED → Verify RED → GREEN → Verify GREEN" 네 포인트 엄수 +- [ ] 테스트 작성 전 프로덕션 코드 타이핑 금지 +- [ ] 각 Step GREEN 직후 전체 `cargo test --lib` 실행하여 회귀 감지 + +## Self-Review 항목 (모든 Step 완료 후) +- [ ] Council synthesis must-fix 8건 모두 반영 +- [ ] Council synthesis should-consider 5건 반영 + plan-reviewer must-fix 4건 반영 (Step 7 RBAC parity, Step 11 guard_layer/correlation_id, Step 13 adapter_filter event, Step 14 Nova/Cinder refilter, Step 16 adapter surface 확인, Step 18 background poll audit) +- [ ] `_shared/tdd-protocol.md` Iron Law 준수 +- [ ] requirements.md의 FR1~FR6 + NFR1~NFR4 모두 대응 +- [ ] A1/A2/A3 Assumption 실측 반영 완료 + +## Plan Review Response (Plan-Reviewer must-fix → 본 plan 반영 매핑) + +| Plan-Reviewer Must-fix | 반영 위치 | 상태 | +|------------------------|-----------|------| +| #1 Step 11 guard_layer + correlation_id 명시 | Step 11 RED+GREEN 개정 | ✅ | +| #2 Step 13 adapter_filter CrossProjectBlockEvent emit | Step 13 RED/GREEN 개정 | ✅ | +| #3 Step 16 adapter surface 실측 확인 | Step 16 상단 `Adapter surface 실측` 섹션 추가 | ✅ | +| #4 Background-task mutation audit | Step 18 신규 (Phase 11) | ✅ | + +| Plan-Reviewer Should-consider | 반영 위치 | 상태 | +|-------------------------------|-----------|------| +| #1 Step 7 RBAC lockstep parity | Step 7 신규 `test_action_to_kind_rbac_mapping_lockstep` | ✅ | +| #2 Step 9 worker raw mpsc 명시 | Step 9 GREEN 주석 | ✅ | +| #3 Step 14 HasTenantId for Server/Volume/Snapshot | Step 14 GREEN 확장 | ✅ | +| #4 e2e mock integration test | Test Strategy 끝에 defer 명시 | 🟡 (defer 정당화) | +| #5 Toast 순서 assertion | Step 11 `test_worker_emits_toast_before_any_api_error` | ✅ (이미 존재) | diff --git a/devflow-docs/construction/bl-p2-085/review-phase1-2-codex.md b/devflow-docs/construction/bl-p2-085/review-phase1-2-codex.md new file mode 100644 index 0000000..43a12d8 --- /dev/null +++ b/devflow-docs/construction/bl-p2-085/review-phase1-2-codex.md @@ -0,0 +1,36 @@ +# Codex Review — Phase 1+2 Foundation (BL-P2-085) + +- **시각**: 2026-04-27 +- **대상**: branch `feature/bl-p2-085-cross-project-scoping` vs `main` (commit `ca2ec2a`) +- **scope**: `--scope branch --base main` +- **diff 규모**: 20 files, +1981 / -899 (소스: action.rs / error.rs / cross_project_guard.rs / infra/mod.rs) +- **결과**: ✅ clean — must-fix 0, should-consider 0 +- **gate 처리**: 통과. Phase 3 Step 4 (CrossProjectBlockEvent + AuditLogger 통합) 진입 가능. + +## 원문 + +``` +# Codex Review + +Target: branch diff against main + +I did not find any discrete, actionable defects in the code changes relative +to the merge base. The Rust changes are additive (new guard types/error +variant/module export/tests) and do not introduce a clear correctness, +security, or maintainability regression on their own. +``` + +## 검사 흔적 (codex-companion 로그) + +- `git diff` (full) + `git diff --name-only` +- `git diff -- src/action.rs src/error.rs ...` (소스만 선별) +- `rg "DispatchedAction|CrossProjectBlocked|check_origin_scope|check_form_selection..."` (사용처 확인) +- `sed src/lib.rs` (모듈 등록 확인) +- `sed src/error.rs` / `sed src/action.rs` (구현 본문) +- `rg "use crate::error::..."` (error 사용 위치) + +## 메타 분석 + +- Codex가 **사용처(call sites)**까지 grep으로 살핌 → 변경된 API가 **아직 호출되지 않은 상태**임을 인지하고도 회귀 가능성 제로로 판단. +- 즉 Phase 3 이후 worker/RBAC/adapter에서 이 API들을 실제로 wire-up할 때 새로운 risk가 생길 수 있다는 함의 (이번 리뷰 범위 밖). +- 다음 리뷰 게이트는 Phase 6~7 완료 후 (envelope 교체 + worker hook) 권장. diff --git a/devflow-docs/construction/bl-p2-085/review-step4-codex.md b/devflow-docs/construction/bl-p2-085/review-step4-codex.md new file mode 100644 index 0000000..7ede353 --- /dev/null +++ b/devflow-docs/construction/bl-p2-085/review-step4-codex.md @@ -0,0 +1,34 @@ +# Codex Review — Phase 3 Step 4 (BL-P2-085) + +- **시각**: 2026-04-27 +- **대상**: `feature/bl-p2-085-cross-project-scoping` vs `ca2ec2a` (Step 4 단독 commit `e68d50b`) +- **scope**: `--scope branch --base ca2ec2a` +- **diff 규모**: 7 files, +336 / -13 (소스 1 신규 + Cargo.toml/lock + mod.rs + docs) +- **결과**: P2 1건 — 즉시 반영 완료 +- **gate 처리**: 통과. Phase 4 Step 5 진입 가능. + +## Findings + +### [P2] Rotate audit log after successful cross-project emits + +`/src/infra/cross_project_audit.rs:92` + +> When `logger.log_entry(...)` succeeds, `emit` returns immediately and never invokes `rotate_if_needed`, so this new audit path bypasses the existing size/retention policy (`MAX_LOG_SIZE`, `MAX_ROTATED_FILES`). In environments where cross-project blocks occur repeatedly, `audit.log` can grow without rotation even though normal app audit writes do rotate; adding a best-effort rotation call after successful writes keeps behavior consistent and prevents unbounded log growth. + +**검증**: `app.rs:788-793`이 정확히 `logger.log_entry(entry)` 직후 `logger.rotate_if_needed()`를 호출하는 패턴이 확립되어 있음을 grep으로 확인. 우리 emit은 success 시 early return으로 rotation skip — 명백한 inconsistency. + +**처리**: 즉시 반영. emit() success branch에 rotate_if_needed() 호출 추가. tracing::warn fallback. 회귀 0 (1390 stable). + +### Codex 검사 흔적 + +- `git diff ca2ec2a151c9652c06dbb408ba27386ba02d50e8` +- `sed src/infra/audit.rs` (rotation API 시그니처 확인) +- `sed src/infra/cross_project_guard.rs` +- `rg "cross_project_audit|CrossProjectBlockEvent|emit("` +- `rg "AuditResult::Failed|log_entry(|emit(|fingerprint|correlation_id"` +- `sed src/app.rs:720,860` (기존 record_audit 패턴 비교) — 핵심 finding의 근거 +- `rg "log_entry("` (호출자 매트릭스) +- `nl src/infra/cross_project_audit.rs:80,130` (emit 본문) +- `sed src/lib.rs` (모듈 등록) + +**메타**: codex가 audit.rs의 별도 `rotate_if_needed` API 존재를 확인하고, 기존 호출자(app.rs:780)의 패턴과 비교한 다음 구조적 inconsistency를 잡아냄. fingerprint v1 canonical schema 결정, sha2 dep 추가, AuditResult 매핑 등은 모두 통과 (P0/P1 0건). diff --git a/devflow-docs/construction/build-and-test/build-instructions.md b/devflow-docs/construction/build-and-test/build-instructions.md deleted file mode 100644 index b44f8ca..0000000 --- a/devflow-docs/construction/build-and-test/build-instructions.md +++ /dev/null @@ -1,32 +0,0 @@ -# Build Instructions — BL-P2-074 - -## Prerequisites -- Rust (edition 2024, toolchain per `rust-toolchain.toml`) -- Cargo (bundled with Rust) -- Network access for first-time dependency fetch - -## Steps - -1. Format check: - ``` - cargo fmt --all --check - ``` -2. Lib/tests build (primary): - ``` - cargo build --lib --tests - ``` -3. Binary build: - ``` - cargo build --bin nexttui - ``` - -## Expected Output -- `cargo fmt --all --check` → 0 diff, exit 0. -- `cargo build --lib --tests` → `Finished 'dev' profile` with 0 errors/warnings. -- `cargo build --bin nexttui` → `target/debug/nexttui` executable produced. - -## Last Verified -- **Commit base**: 551265b (main) -- **Branch**: feat/bl-p2-074-switch-cloud-wire -- **Timestamp**: 2026-04-20T10:15+09:00 -- **Status**: ✅ 전부 통과 diff --git a/devflow-docs/construction/build-and-test/test-instructions.md b/devflow-docs/construction/build-and-test/test-instructions.md deleted file mode 100644 index e956b45..0000000 --- a/devflow-docs/construction/build-and-test/test-instructions.md +++ /dev/null @@ -1,59 +0,0 @@ -# Test Instructions — BL-P2-074 - -## Unit + Integration Tests -Run: -``` -cargo test --lib --tests -``` -Expected: **1328 passed, 0 failed** (baseline 1314 + 14 신규). - -## Clippy Gate -Run: -``` -cargo clippy --lib --tests -- -D warnings -``` -Expected: `Finished 'dev' profile` with no warnings. - -## Format Check -Run: -``` -cargo fmt --all --check -``` -Expected: no diff. - -## BL-P2-074 신규 테스트 (14건) - -| 테스트 | 위치 | 검증 | -|---|---|---| -| test_not_configured_displays_human_readable | context/error.rs | NotConfigured Display 문구 (FR-8) | -| test_clone_preserves_not_configured | context/error.rs | Clone arm (FR-8) | -| test_load_clouds_yaml_without_default_project_yields_none | config.rs | serde default backward-compat (FR-2, NFR-2) | -| test_load_clouds_yaml_with_default_project_yields_some | config.rs | 필드 역직렬화 (FR-2) | -| test_cloud_directory_default_project_reflects_config | context/resolver.rs | trait 메서드 (FR-3) | -| test_default_project_returns_configured_value | context/config_cloud_directory.rs | production impl (FR-3) | -| test_default_project_none_when_unset | context/config_cloud_directory.rs | 미설정 케이스 (FR-3) | -| test_context_request_cloud_only_is_constructible | context/types.rs | variant 생성 (FR-2) | -| test_resolve_cloud_only_returns_default_project_target | context/resolver.rs | 성공 경로 (FR-3) | -| test_resolve_cloud_only_unknown_cloud_returns_not_found | context/resolver.rs | 실패 unknown cloud (FR-5) | -| test_resolve_cloud_only_no_default_returns_not_configured | context/resolver.rs | 실패 no default (FR-5, FR-8) | -| test_resolve_cloud_only_stale_default_returns_not_found | context/resolver.rs | 실패 stale (FR-5) | -| test_switcher_noop_on_same_target | context/switcher.rs | idempotent epoch 불변 (FR-4, D1) | -| test_switch_back_after_cloud_only_returns_previous_target | context/switcher.rs | D4 CloudOnly→switch-back | - -대체된 테스트: `test_command_bar_switch_cloud_emits_info_toast` → `test_command_bar_switch_cloud_dispatches_context_request_without_toast` (app.rs — FR-1, dispatch 검증 + legacy stub 문구 미발행 assertion). - -## Manual Verification -1. 기본 flow — CLI 또는 devstack 환경 필요: - ``` - cargo run --bin nexttui -- --cloud devstack - ``` - → `:switch-cloud prod` 입력 시 `CloudConfig::default_project` 설정에 따라 전환 또는 `NotConfigured` 토스트. - -2. clouds.yaml에 `default_project`가 없는 cloud로 `:switch-cloud` 호출 → 토스트: - > cloud '' has no default project — use :switch-project - -## Last Verified -- **Commit base**: 551265b (main) -- **Branch**: feat/bl-p2-074-switch-cloud-wire -- **Timestamp**: 2026-04-20T10:15+09:00 -- **Status**: ✅ 1328/1328 pass, clippy clean, fmt clean diff --git a/devflow-docs/inception/application-design.md b/devflow-docs/inception/application-design.md index bf56ded..a05aa52 100644 --- a/devflow-docs/inception/application-design.md +++ b/devflow-docs/inception/application-design.md @@ -1,236 +1,702 @@ # Application Design -**Mode**: DETAIL (상세 설계 단계) -**Timestamp**: 2026-04-20T08:10:00+09:00 (LIST), 2026-04-20T08:25:00+09:00 (DETAIL) -**BL**: BL-P2-074 (SwitchCloud wire 완결) -**Depth**: Standard +**Mode**: LIST + DETAIL + COUNCIL-REVISION +**Timestamp**: 2026-04-24T13:45:00+09:00 (Council 리뷰 반영) +**Review Ref**: `devflow-docs/inception/design-review-raw/synthesis.md` (must-fix 7건 반영) +**Work Item**: BL-P2-085 — Cross-project scoping 전면 fix +**Approach**: A안 Atomic Security Fix + +## Assumption Verification (LIST 선결 실측 — Council 리뷰 후 교정) + +| Assumption | 결과 | Notes | +|------------|------|------| +| A1 active_scope 전파 | ✅ (type refs 교정) | **실제 타입 (Codex 리뷰에서 지적)**: `TokenScope` enum (`src/port/types.rs:42`)는 `Project { name, domain }` variant로 project_id **없음**. `ScopedAuthSession` (`src/adapter/auth/scoped_session.rs:33`)도 project_id/name 필드 **없음** (ports만 보유). 실제 project_id 소스: **`Token.project: ProjectScope { id, name, domain_id, domain_name }`** (`src/port/types.rs`). 앱 레벨 캐시: **`RbacGuard::project_id() -> Option`** (`src/infra/rbac.rs:113`). 1차 소스는 Token.project.id, RbacGuard는 파생 캐시. | +| A2 mock HTTP 인프라 | ⚠️ 부정 | dev-deps에 `mockito`/`wiremock` 부재. **설계 영향**: FR1은 (a) URL/query pure fn 테스트 + (b) **client-side re-filter** (adapter에서 응답 파싱 후 scope 불일치 리소스 제거, defense-in-depth) 단위 테스트 조합으로 수용 기준 커버. HTTP 서버 mock dev-dep은 회피. | +| A3 `build_disambiguated_opts` 순수성 | ✅ | `src/module/server/mod.rs:32` 순수 제네릭 함수, 2 호출부 (lines 1103, 1115). 이번 BL에선 확장 없이 유지. | + +## 기존 구조 주요 발견 (Council 리뷰 후 보강) + +- `src/infra/rbac.rs`에 `RbacGuard`(`project_id()`, `can_perform(ActionKind)`, `can_access_route`, `has_capability`) + `ActionKind` enum + `EffectiveRole` 이미 존재. + - FR3는 **신규 struct가 아니라 `RbacGuard` 확장**. +- **`action_to_kind()` 매핑 이미 존재** (`src/worker.rs:151`, Codex 리뷰 발견): 모든 Action을 `Option`로 분류. `Some(_)` == mutation, `None` == read-only. → **신규 `is_mutation()` 도입 불필요**, 이 기존 함수를 `is_mutation(a) = action_to_kind(a).is_some()` 형태로 재사용. +- **`ActionSender` + `VersionedEvent` 중앙 envelope 이미 존재** (`src/context/action_channel.rs:23`, `src/context/versioned.rs`, Codex 리뷰 발견): + - `ActionSender { tx: mpsc::UnboundedSender> }` + - `VersionedEvent { payload: T, epoch: Epoch }` — generic, T 교체 가능 + - **설계 전환**: 55 call site 수정 대신 **이 envelope에 origin_project_id를 통합**. T = Action을 T = DispatchedAction으로 교체. +- `run_worker` (`src/worker.rs:53`)가 단일 worker 엔트리. action_to_kind()가 이미 line 67에서 호출됨. 가드 hook은 이 근처. +- `NeutronHttpAdapter::new(auth: Arc, region)` — auth trait 주입 이미 존재. `AuthProvider::get_token_info().project.id`로 active project_id 취득. + +## 컴포넌트 목록 (Council 리뷰 후 개정) + +| 컴포넌트 | 책임 | 타입 | 신규/확장 | 위치 | 해당 FR | +|---------|------|------|----------|------|---------| +| ~~`ActionKind::is_mutation()`~~ | ~~신규 분류 메서드~~ | ~~Util~~ | **폐기** | — | FR2 (action_to_kind()로 충족) | +| `action_to_kind()` parity guard | **기존 함수** 유지. is_mutation 의미는 `action_to_kind(a).is_some()`. RBAC ActionKind 매핑과의 parity 테스트 추가 (미분류 Action 회귀 방지) | Test | 보강 | `src/worker.rs:151` + 테스트 | FR2 | +| ~~`StampedAction` wrapper~~ | ~~Action + origin_project_id 래핑~~ | ~~Util~~ | **폐기 → DispatchedAction으로 교체 (아래)** | — | FR2 | +| `DispatchedAction` | 중앙 envelope 페이로드. `{ action: Action, origin_project_id: Option }`. `VersionedEvent`의 T 파라미터로 사용. read-only Action은 `None`, mutation Action은 `Some(scope)` | Util | 신규 | `src/action.rs` (Action enum 옆) | FR2 | +| `RbacGuard::check_project_scope()` | role-tier 가드 위에 project_id 일치 검사 누적(AND) 적용. reason(`role_tier`/`project_scope`/`both`) 분리 반환 | Service | 확장 | `src/infra/rbac.rs` | FR3 | +| `RbacGuard::update_roles_preserve_project()` | token refresh 시 project_id=None 덮어쓰기 방지. 명시적 preserve API 또는 기존 update_roles를 `Option>` 시맨틱으로 수정 | Service | 확장 | `src/infra/rbac.rs` | FR3 (token refresh 방어) | +| `CrossProjectGuard` | origin_project_id vs current active 비교 + form selection 검증 순수 함수. RbacGuard와 협업 — worker/form 공통 호출부 | Util | 신규 | `src/infra/cross_project_guard.rs` | FR2, FR3, FR4 공통 | +| `ActionSender::send()` 중앙 스탬핑 | send 호출부에서 current scope 취득 후 `DispatchedAction { action, origin }`로 감싸 `VersionedEvent` 발행. 호출 레벨 변경 0 | Service | 확장 | `src/context/action_channel.rs` | FR2 | +| `WorkerMutationGuardHook` | `run_worker`의 epoch-match 직후, DispatchedAction에서 `action_to_kind(action).is_some()`인 경우 `CrossProjectGuard::check_origin` 호출. 실패 시 Action 거부 + 이벤트 emit + 토스트 전파 | Service | 확장 | `src/worker.rs` | FR2 | +| `NeutronListQueryBuilder` | SG/Network/FloatingIp/Subnet List 빌더에 `tenant_id` 주입. URL 조립 pure fn 추출 | Adapter | 확장 | `src/adapter/http/neutron.rs` | FR1 | +| `NeutronResponseRefilter` | 응답 파싱 결과에 대해 **client-side re-filter** (project_id != active인 리소스 제거). defense-in-depth | Adapter | 확장 | `src/adapter/http/neutron.rs` (paginated_list 결과 후처리) | FR1 (응답측 수용기준) | +| `NovaCinderAllTenantsPolicy` | nova/cinder list 엔드포인트 `all_tenants` 플래그 기본 false 명시 + opt-in 파라미터 + 응답 re-filter | Adapter | 확장 | `src/adapter/http/nova.rs`, `src/adapter/http/cinder.rs` | FR1 | +| `FormSelectedIdValidator` | 제출 시점 선택된 리소스 ID의 project_id vs active scope 비교. 실패 시 form reject + 에러 토스트. **Glance 이미지 등 FR1 제외 리소스의 mutation path에 필수 적용**. 공통 헬퍼 | Util | 신규 | `src/module/common/scope_validator.rs` | FR4 | +| `CrossProjectBlockEvent` | 이벤트 스키마 struct + fingerprint(version-prefixed + delimiter + 명시적 None 규칙) 계산 + tracing emit + tag prefix `[cross-project-guard]`. best-effort append | Util | 신규 | `src/infra/audit/cross_project_block.rs` | FR5 | +| `CrossProjectToast` | 차단 시 공통 카피 생성. `active_project_name` + `target_project_name`/`origin_project_name` 포함. Toast level = Warning. 단일 정의 공통 모듈 | Util | 신규 | `src/ui/cross_project_toast.rs` | FR6 | + +총 **12개 컴포넌트** (신규 5, 확장 7, 폐기 2). 이전 LIST의 9개에서 개편: +- `ActionKind::is_mutation()` 폐기 (기존 action_to_kind 재사용) +- `StampedAction` 폐기 → `DispatchedAction` + `ActionSender::send()` 중앙 스탬핑으로 대체 +- `NeutronResponseRefilter` 신규 (FR1 응답측 수용기준 + defense-in-depth) +- `RbacGuard::update_roles_preserve_project` 신규 (token refresh 방어) +- `action_to_kind parity guard` 보강 (테스트) + +## DETAIL 단계에서 확정 기대 + +1. **Q1 정식 확정** `tenant_id` 필터 불가 endpoint 매트릭스 — keystone 제외 nova/neutron/cinder/glance 전수 조사. 불가 endpoint는 worker/RBAC 레벨에서 커버. +2. **Q3 정식 확정** Worker 거부 에러 variant — 신규 `WorkerError::CrossProjectBlocked { actor, active, target }` vs 기존 `RbacDenied` 재사용. `src/worker.rs`의 현재 에러 타입 + `src/error.rs`와 호환성 확인. +3. **A1 교정 반영**: "active_scope" → `TokenScope` 용어 통일 (requirements.md Change Log에 후속 반영 권고). +4. **`AuthProvider` trait surface 확인**: `project_id()` 메서드 제공 여부. 없으면 추가 필요. +5. **`run_worker` Action target 식별**: `Action`이 `target_project_id()`를 어떻게 노출하는지 (parameter? enum variant? self.target?). Worker guard hook 신호 보강 필요 여부. +6. **FormSelectedIdValidator 위치 확정**: `src/module/common/` 존재 여부. 없으면 `src/ui/` 쪽 배치. +7. **에러 전파 경로**: Worker 거부가 Toast로 올라가는 채널 (기존 `Event` / `Toast` 경로 재사용). + +## Scope Exclusions (재확인) + +- `build_disambiguated_opts` 다른 모듈 확장 = 제외 +- 감사 로그 뷰어 / 패턴 대시보드 = 제외 (스키마만 확정) +- PII 해싱 = 제외 +- HTTP 서버 mock dev-dep 추가 = 회피 (pure fn 추출로 대체) + +## Decision Log + +- **[2026-04-24] A2 mitigation = pure fn 추출**: mockito/wiremock-rs 도입 대신 URL/query 빌더를 pure function으로 추출하여 단위 테스트. 근거: (a) 이번 BL은 보안 경계 fix지 테스트 인프라 근대화가 아님 (스코프 규율), (b) FR1 수용기준의 본질은 URL 조립까지이며 그 이후는 reqwest stable, (c) 기존 adapter 테스트 스타일(serde body 단위)과 일관, (d) mock server가 필요해지면 BL-P2-081(HTTP 경로 자체 손대는 BL)에서 도입이 자연스러움. +- **[2026-04-24] FR2 구현 = 2b Stamped wrapper**: `Action` enum이 `project_id`를 per-variant로 가지지 않음을 실측 확인 (55 dispatch site). 중앙 dispatch 지점에서 `StampedAction { action, origin_project_id }` 래퍼로 감싸 worker가 `origin == current_active`를 검사. 근거: TOCTOU + cache-stale 시나리오를 정확히 커버하면서 diff 최소. ID 위조 시나리오는 FR1+FR4에 위임 (TUI 환경에서 현실적 위협 최소). +- **[2026-04-24] Q3 = AppError 확장**: 신규 `WorkerError` enum 도입 대신 기존 `AppError`(`#[non_exhaustive]`)에 variant 추가. 근거: 현재 프로젝트에 `WorkerError`/`RbacError` 분리된 타입 없고 `AppError`가 공통 채널. 미래 확장 여지는 `#[non_exhaustive]`로 보존. + +--- + +## 컴포넌트 상세 설계 (DETAIL, Standard depth) + +아래는 각 컴포넌트의 **public interface**(주요 2~3 메서드) + **의존성**. 실제 시그니처는 TDD RED 단계에서 테스트가 강제하는 대로 세부 조정됨. + +### C1. `action_to_kind()` parity guard [보강 — 신규 `is_mutation()` 폐기] + +**Responsibility**: 기존 `action_to_kind(action: &Action) -> Option` 함수(`src/worker.rs:151`)를 "mutation 분류자"로 재사용. `Some(_)` == mutation, `None` == read-only. + +**Interface** (변경 없음, 이미 존재): +```rust +// src/worker.rs:151 (이미 구현됨) +fn action_to_kind(action: &Action) -> Option { + match action { + Action::CreateServer(_) | ... => Some(ActionKind::Create), + Action::DeleteServer { .. } | ... | Action::DeleteImage { .. } => Some(ActionKind::Delete), + Action::DeleteVolume { force: true, .. } => Some(ActionKind::ForceDelete), + // ... 읽기 액션은 해당 arm에서 None + _ => None, + } +} -## Scope Note +// 신규 편의 함수 (옵션) +#[inline] +pub fn action_is_mutation(action: &Action) -> bool { + action_to_kind(action).is_some() +} +``` + +**보강할 것**: +1. **Parity 테스트**: 모든 `Action::*` variant에 대해 `action_to_kind` 매핑을 exhaustive match로 돌려 fallthrough(`_`)를 제거. 신규 variant 추가 시 컴파일 실패로 강제. +2. **read-only variant 리스트 명시화**: 현재 `_ => None` fallthrough를 각 `Fetch*`/`Navigate`/`Back` 등으로 풀어서 exhaustive화. -BL-P2-074는 **신규 컴포넌트가 아닌 기존 컴포넌트 7개 확장**. `src/context/` 주요 타입과 `src/config.rs` + `src/app.rs`의 narrow surgical change. Single-component system으로 보기엔 trait/enum/config가 서로 얽혀있어 LIST로 정리할 가치 있음. +**Test strategy**: `test_action_to_kind_cud_actions` (이미 존재, line 939)에 다음 추가: +- 모든 `Fetch*` / `Navigate` / `Back`에 대해 `action_to_kind(a) == None` 검증 +- 모든 mutation variant에 대해 `Some(_)` 검증 (범주별 정확 매핑 assertion) +- Glance 특히: `DeleteImage` / `CreateImage` 검증 (Codex의 Glance gap 보강) -## 컴포넌트 목록 (확장/변경) +**Dependencies**: 없음 (기존 함수 유지 + 테스트 보강). -| 컴포넌트 | 책임 | 타입 | -|---------|------|------| -| `CloudConfig` | clouds.yaml cloud 설정 — 신규 `default_project: Option` 필드 추가 (auth.project_name과 분리) | Util (config struct) | -| `ContextRequest` | switch 요청 타입 — 신규 `CloudOnly { cloud: String }` variant 추가 | Util (enum) | -| `SwitchError` | switch 실패 타입 — 신규 `NotConfigured { cloud: String }` variant 추가 | Util (enum) | -| `CloudDirectory` | cloud 메타데이터 접근 trait — 신규 `default_project(&str) -> Option` 메서드 추가 | Interface (trait) | -| `ConfigCloudDirectory` | Config 래퍼 — `default_project` 구현 (CloudConfig 참조) | Repository | -| `ContextTargetResolver` | Request → ContextTarget 변환 — `CloudOnly` arm 신설 (default_project 위임 + `ByName` 경로 재사용) | Service | -| `App::execute_command` | `:switch-cloud` handler — toast-only stub 제거 → `Action::SwitchContext(CloudOnly { cloud })` dispatch | Controller | +--- -총 7개 컴포넌트 확장. +### C2. `DispatchedAction` envelope + `ActionSender` 중앙 스탬핑 [신규 → 기존 `StampedAction` 폐기] -## 영향을 받지만 변경 없는 컴포넌트 (컴파일러 강제 재확인) +**핵심 설계 변경**: Council 리뷰에서 **기존 `ActionSender` + `VersionedEvent` 중앙 envelope** (`src/context/action_channel.rs`) 발견. 새 래퍼를 55 call site에 적용하는 대신 **기존 envelope의 T 파라미터를 교체**. -아래 컴포넌트는 `ContextRequest` / `SwitchError`에 variant가 추가되며 매처가 자동 실패 → 업데이트 필수. 로직은 "CloudOnly를 ByName 경로로 위임"으로 수렴하므로 추가 책임 없음. +**Responsibility**: Action에 origin project_id 스탬프를 부착. `ActionSender::send()` 내부에서 자동 스탬핑. -- `src/context/switcher.rs::SwitchContextSwitcher` — request match arm -- `src/worker.rs` — context-switch dispatch 경로 (~line 790) -- `CloudDirectory` impl 사이트 **5개** (production 1 + test doubles 4): - - production: `src/context/config_cloud_directory.rs::ConfigCloudDirectory` (확장 — `default_project` 구현) - - test: `src/context/resolver.rs::tests::FakeClouds` - - test: `src/context/switcher.rs::tests::FakeClouds` - - test: `src/app.rs::tests::FakeClouds` (~line 3444) - - test: 기타 추가 test double (신규 테스트 추가 시) +**Location**: `src/action.rs` (DispatchedAction struct) + `src/context/action_channel.rs` (ActionSender 확장) -## 확정된 설계 결정 +**Interface**: +```rust +// src/action.rs +#[derive(Debug, Clone)] +pub struct DispatchedAction { + pub action: Action, + pub origin_project_id: Option, // Some for mutation, None for read-only +} -### D1. FR-4 idempotent 체크 위치 → **`ContextSwitcher::switch` 내부**, resolver 직후 / `try_begin` 직전 +// src/context/action_channel.rs +pub struct ActionSender { + tx: mpsc::UnboundedSender>, // T 교체 (Action → DispatchedAction) + epoch_source: Arc<...>, // 기존 + scope_provider: Arc, // 신규: current project_id 취득 +} -**근거**: `src/context/switcher.rs:64-77`의 step 1→2 경계. resolver가 성공(Ok)한 후 `state.try_begin`이 epoch을 bump하기 전에 비교해야 epoch 낭비 없음. app-level pre-check는 resolver를 거치지 않아 default_project가 이미 현재 project인지 판단 불가. +pub trait ScopeProvider: Send + Sync { + fn current_project_id(&self) -> Option; +} -``` -현재 step: resolve → try_begin(epoch++) → cancel → session.begin → ... → commit -신규 step: resolve → [IDEMPOTENT CHECK] → try_begin → ... +impl ActionSender { + pub fn send(&self, action: Action) -> Result<(), SendError> { + let origin = if action_is_mutation(&action) { + self.scope_provider.current_project_id() + } else { + None + }; + let dispatched = DispatchedAction { action, origin_project_id: origin }; + let envelope = VersionedEvent::new(dispatched, self.epoch_source.current()); + self.tx.send(envelope) + } +} ``` -**알고리즘** (두 옵션, 구현 단계에서 택일): -- **옵션 α (권장)**: 기존 `self.state.state()` 재사용 — `SwitchStateView::Idle { current }`에서 `current: ContextSnapshot` 추출. 신규 메서드 불필요. -- **옵션 β**: `SwitchStateMachine::current_snapshot() -> Option` 얇은 read-only accessor 신규 추가 (RwLock peek). Idle 분기가 아닌 다른 상태에서도 "마지막 committed" 제공 가능. +**왜 이 설계인가 (Council 근거)**: +- **Codex**: "Stamp once at `ActionSender` boundary (centralized)" — 55 call site 수정 회피. +- **Gemini**: "Centralize stamping — `app_ctx.dispatch()` helper" — 동일 맥락. +- 기존 `ActionSender::send(action)` 호출 코드는 **변경 없음**. 내부 구현만 교체. -선택 기준: `state_machine.rs:136-178` 확인 결과 `state()` + `previous_in_flight()`는 존재하나 `current_snapshot()`은 부재. 단순성 + delta 최소화 원칙으로 **α 우선**. +**Dependencies**: +- `Action` (기존), `VersionedEvent` (기존 generic), `Epoch` (기존) +- 신규: `ScopeProvider` trait (Token/RbacGuard 어느 쪽을 1차 소스로 삼을지는 RED 단계 확정) + +**Change footprint** (Codex 약속 "~5 파일"): +1. `src/action.rs` — `DispatchedAction` struct 추가 +2. `src/context/action_channel.rs` — ActionSender 시그니처 + send 내부 stamp +3. `src/context/versioned.rs` — 변경 없음 (generic) +4. `src/worker.rs` — receive 쪽 `VersionedEvent`로 payload 타입 교체 + 내부에서 `.action` 추출 +5. `src/app.rs` — ActionSender 생성자에 ScopeProvider 주입 (1~2곳) +6. (테스트용) `src/app.rs` 등에서 `FakeActionSender` 패턴 업데이트 -**TOCTOU 주의** (I1 리뷰 반영): -- peek → `try_begin` 사이에 동시 switch가 끼어들어 `try_begin`을 먼저 잡는 race는 현재 알고리즘에서 **"의도상 no-op인데 `InProgress` 반환"** 시나리오를 만듦. -- state_machine의 check-and-bump가 이미 Mutex로 원자화되어 있으므로 loser가 `InProgress`를 받는 동작 자체는 일관. FR-4의 "동일 target 재입력 no-op" acceptance는 **단일 호출자 컨텍스트에서만** 보장함을 명시 — 동시 호출 시 InProgress는 허용. -- 측정 테스트는 순차 2회 호출로 한정 (동시성 race는 별도 테스트가 아님). +**Test strategy**: +- `DispatchedAction { action: mutation, origin: Some("A") }` 생성 시 대칭 스탬프 테스트 +- `DispatchedAction { action: read_only, origin: None }` 규약 테스트 +- ActionSender integration: scope=A에서 send → origin=A 스탬프 확인 +- read-only(Fetch*)는 origin=None 확인 -**측정 (FR-4 acceptance)**: `test_switcher_noop_on_same_target`에서 switch 2번 순차 호출 후 `state.epoch().current()`가 불변임을 assert. +--- -### D2. `SwitchError::NotConfigured` 메시지 최종 문구 +### C3. `CrossProjectGuard` [신규] +**Responsibility**: `origin_project_id`(또는 target ID의 project_id) vs 현재 active scope 비교의 **순수 함수**. worker / form validator / RBAC 에서 공통 호출. +**Location**: `src/infra/cross_project_guard.rs` + +**Interface**: ```rust -#[error("cloud '{cloud}' has no default project — use :switch-project ")] -NotConfigured { cloud: String }, +pub enum GuardDecision { + Allow, + Block { reason: CrossProjectReason }, +} + +pub enum CrossProjectReason { + /// Action origin scope != current active scope + OriginScopeMismatch { origin: String, active: String }, + /// Form-selected resource's project_id != active scope + FormSelectionMismatch { selected: String, active: String }, + /// Adapter response contained cross-project resource (invariant violation) + AdapterFilterViolation { resource_id: String, project_id: String }, +} + +pub fn check_origin_scope(origin: &str, active: &str) -> GuardDecision { ... } +pub fn check_form_selection(selected_project_id: &str, active: &str) -> GuardDecision { ... } ``` -- `#[error(...)]` 안에 cloud 값을 직접 포함 (safe_display는 toast 레이어에서 60자 truncate 적용 — Display는 정직한 원문). -- `:switch-project ` 리터럴 유지 — Help toast(src/app.rs:1758)와 동일 표기. -- **struct variant 선택 근거** (S1 리뷰 반영): 기존 `SwitchError`는 대부분 tuple variant(`NotFound(String)`, `RescopeRejected(String)`)지만 `Ambiguous { candidates: Vec }` 전례가 있음. 향후 "왜 not configured인지" 맥락 확장(예: `reason: MissingField | ExplicitNull`)을 위해 struct variant로 선택. 현재는 `cloud` 한 필드. +**Dependencies**: 없음 (순수 함수 모듈). `CrossProjectReason`은 FR5 `reason` 필드의 rust-side 소스 enum. `reason` 문자열 매핑은 `CrossProjectBlockEvent::from(reason)`에서. -### D3. `CloudConfig::default_project` 직렬화 위치 → **cloud 레벨 직하** +**Test strategy**: 각 케이스별 단위 테스트 (match/mismatch). -```yaml -clouds: - prod: - auth: - auth_url: https://keystone/v3 - username: admin - password: secret - project_name: admin # bootstrap scope (unchanged) - default_project: my_workload # 신규 — runtime switch-cloud default - region_name: RegionOne -``` +--- -**근거**: -- `CloudConfig`에 이미 `region_name`, `identity_api_version` 등 cloud 레벨 필드가 존재. `default_project`도 동일 레벨에 배치하면 일관성 유지. -- `auth.default_project`로 두면 bootstrap scope와 섞여 개념 혼동 재발 (H1 adversarial 문제 반복). -- `app.default_project`는 app-level 설정(`AppConfig`)과 충돌 — AppConfig는 single-value, CloudConfig는 per-cloud. +### C4. `RbacGuard::check_project_scope()` [확장] +**Responsibility**: 기존 `can_perform(ActionKind)` role-tier 검사에 project-scope 일치 검사를 AND 누적. -**serde 호환성**: -- `#[serde(default)]` 적용 → 기존 clouds.yaml 100% backward compatible. -- `CloudConfig` 자체는 `Deserialize` 외에 `Serialize`도 이미 구현(config.rs:53). 신규 필드도 파생 매크로로 자동. +**Location**: `src/infra/rbac.rs` -**Backward-compat 테스트 acceptance** (S3 리뷰 반영): -- `test_load_clouds_yaml_without_default_project_yields_none` — `default_project` 필드가 없는 기존 YAML 로드 시 `CloudConfig::default_project == None` 검증. -- `test_load_clouds_yaml_from_standard_path`(config.rs:509)와 동일 테스트 헬퍼 패턴 재활용. +**Interface**: +```rust +impl RbacGuard { + // 신규 메서드 추가 + pub fn check_project_scope(&self, target_project_id: &str) -> GuardDecision { + match &self.project_id { + Some(active) if active == target_project_id => GuardDecision::Allow, + Some(active) => GuardDecision::Block { + reason: CrossProjectReason::OriginScopeMismatch { + origin: target_project_id.into(), + active: active.clone(), + }, + }, + None => GuardDecision::Block { ... }, // scope 미설정 시 fail-safe: deny + } + } -### D4. `ContextSnapshot`의 CloudOnly 원본 보존 → **보존하지 않음** (Assumption 7 확정) + // 기존 can_perform 확장 + pub fn can_perform_in_scope(&self, action: ActionKind, target_project_id: &str) -> GuardDecision { + let role_pass = self.can_perform(action); + let scope_pass = matches!(self.check_project_scope(target_project_id), GuardDecision::Allow); + match (role_pass, scope_pass) { + (true, true) => GuardDecision::Allow, + (false, true) => ... // role_tier, + (true, false) => ... // project_scope, + (false, false) => ... // both, + } + } +} +``` -**근거**: -- `:switch-back`은 "이전 `ContextTarget`으로 되돌아간다" — resolved target이 복귀 대상. Request origin(ByName vs CloudOnly)은 rollback 시맨틱에 영향 없음. -- `ContextHistoryStore`는 이미 `ContextSnapshot`만 저장. 변경 없이 재활용. -- 만약 "어떻게 들어왔는지" 추적이 필요하면 `tracing` 필드로 충분 (NFR-6). +**Dependencies**: `CrossProjectGuard::CrossProjectReason` (C3), 기존 `self.project_id`, `ActionKind`. -**테스트**: `test_switch_back_after_cloud_only_returns_previous_target` — CloudOnly로 진입 후 switch-back → 원래 target 반환 확인. +**Test strategy**: role×scope 매트릭스 (admin/member/reader × match/mismatch). reason 정확히 구분 검증. -## 컴포넌트 상세 설계 +--- -### CloudConfig (확장) -**Responsibility**: clouds.yaml에 선언된 cloud별 설정을 담는 serde struct. 신규 `default_project` 필드로 runtime `:switch-cloud`의 기본 전환 대상을 명시. -**Interface**: -- `pub default_project: Option` — `#[serde(default)]`. None이면 `:switch-cloud ` 호출 시 `NotConfigured`. -- 기존 getter/setter는 변경 없음. -**Dependencies**: 없음 (pure data struct, `Clone + Deserialize + Serialize`). +### C4-bis. `RbacGuard::update_roles_preserve_project()` [확장 — Council 필수] + +**Responsibility**: Token refresh 이벤트가 `RbacGuard::update_roles(roles, None)`로 project_id를 덮어쓰지 않도록 방어. Codex가 발견한 위협: refresh가 roles만 갱신하는데 함께 project_id=None을 넣어버리면 FR3 scope check가 always-deny로 깨짐. -### ContextRequest (확장) -**Responsibility**: 사용자 입력을 resolver 입력 형태로 정규화한 unresolved request. -**Interface** (enum variant 추가): +**Location**: `src/infra/rbac.rs` + +**Interface (2가지 중 택1, RED에서 확정)**: ```rust -pub enum ContextRequest { - ByName { cloud: Option, project: String, domain: Option }, - ById { cloud: Option, project_id: String }, - CloudOnly { cloud: String }, // 신규 +// (a) 신규 preserve API +impl RbacGuard { + pub fn update_roles_preserve_project(&self, roles: Vec) { ... } + // 기존 update_roles는 "명시적으로 project_id 변경이 있는 경우"에만 사용 +} + +// (b) 시맨틱 개선: Option> — 명시적 "프로젝트는 변경 없음" +impl RbacGuard { + pub fn update_roles(&self, roles: Vec, project_id: ProjectIdUpdate) { ... } } +enum ProjectIdUpdate { Keep, Set(Option) } ``` -**Dependencies**: 없음. 매처 4개 사이트 업데이트 강제 (resolver/switcher/worker/app) — `#[non_exhaustive]` 부재로 컴파일 실패가 차단. -### SwitchError (확장) -**Responsibility**: switch 경로 실패 타입. -**Interface**: +**Dependencies**: 기존 `self.project_id: RwLock>` 내부 상태. + +**Test strategy**: refresh 이벤트가 도는 mock 시나리오에서 `RbacGuard::project_id()`가 새 토큰의 project scope와 동일하게 유지되는지 assertion. + +--- + +### C5. `WorkerMutationGuardHook` [확장] +**Responsibility**: `run_worker`의 Action dispatch 루프 진입 직후(epoch 매치 이후), `DispatchedAction`에서 mutation인 경우 `CrossProjectGuard::check_origin_scope` 호출. 실패 시 Action 실행 차단, `CrossProjectBlockEvent` emit, Toast 채널로 사용자 알림, `AppError::CrossProjectBlocked` 반환. + +**Location**: `src/worker.rs` (run_worker 함수 내부, line 67 부근 epoch 체크 직후) + +**Pseudo-flow** (Council 리뷰 반영 개정): ```rust -pub enum SwitchError { - // ... 기존 9개 variant ... - #[error("cloud '{cloud}' has no default project — use :switch-project ")] - NotConfigured { cloud: String }, // 신규 -} +run_worker(mut rx: ActionReceiver, ctx): // rx가 이제 VersionedEvent 기반 + while let Some(envelope) = rx.recv().await: + let (dispatched, epoch) = envelope.into_parts(); + if !ctx.epoch_matches(epoch) { continue; } // 기존 stale drop + + let DispatchedAction { action, origin_project_id } = dispatched; + let current_active = ctx.auth.get_token_info().await?.project.id; + + if let Some(origin) = origin_project_id { + // mutation 경로 + match CrossProjectGuard::check_origin_scope(&origin, ¤t_active) { + Allow => proceed_dispatch(action), + Block { reason } => { + let ev = CrossProjectBlockEvent::new(reason, &action, &ctx); + ev.emit(); // tracing::warn! best-effort + toast_channel.send(CrossProjectToast::build_origin_mismatch(...)).await; + // AppError::CrossProjectBlocked 상위 반환 경로 (현재 worker는 err 채널이 없어 이벤트로 전파) + continue; + } + } + } else { + proceed_dispatch(action); // read-only는 guard 우회 + } ``` -**Dependencies**: -- `Clone` impl 수동 확장 (error.rs:38-57): `Self::NotConfigured { cloud } => Self::NotConfigured { cloud: cloud.clone() }`. -- 사용자 facing 경로: resolver → switcher → app toast. Toast 레이어에서 `safe_display(60)` 적용. -- **Clone 테스트 acceptance** (S2 리뷰 반영): `error.rs::tests`에 `test_clone_preserves_not_configured` 추가 — clone이 cloud 문자열을 보존함을 assert. -### CloudDirectory (trait 확장) -**Responsibility**: cloud 메타데이터의 read-only 추상화 (resolver가 Config 구현에 직접 의존하지 않도록 분리). -**Interface**: +**Dependencies**: `DispatchedAction` (C2), `CrossProjectGuard` (C3), `CrossProjectBlockEvent` (C9), `CrossProjectToast` (C10), `AuthProvider::get_token_info()`, `action_to_kind()` (C1, 이미 존재). + +**주의** (Codex 지적): 현재 worker는 "epoch 기반 stale action drop"이 없음 (이벤트 드롭만 존재). 이 BL에서는 **origin-stamp가 스탈 케이스를 커버** (origin != active 시 차단). 전면 stale-action drop은 future BL (BL-P2-086 후보). + +**Test strategy**: mock worker 루프에서 각 케이스: +- (origin=A, active=A) + mutation: 실행 허용 +- (origin=A, active=B) + mutation: 차단 + 이벤트 + 토스트 +- origin=None + read-only: 실행 허용 +- Toast가 ApiError보다 먼저 emit되는 순서 검증 (Gemini 권고) + +--- + +### C6. `NeutronListQueryBuilder` [확장 + pure fn 추출] +**Responsibility**: neutron list 엔드포인트 URL에 `tenant_id` 쿼리 파라미터 주입. URL 조립을 **pure function**으로 분리. + +**Location**: `src/adapter/http/neutron.rs` + +**Affected endpoints (Q1 매트릭스 partial)**: +| Endpoint | 현재 상태 | 변경 | +|----------|----------|------| +| `list_networks` | `_filter` 무시 | `tenant_id={scope}` 주입 | +| `list_security_groups` | `_filter` 무시 | `tenant_id={scope}` 주입 | +| `list_floating_ips` | `_filter` 무시 | `tenant_id={scope}` 주입 | +| `list_subnets` | filter minimal | `tenant_id={scope}` 주입 | +| `list_ports(device_id)` | device 범위 | 변경 없음 (device가 scope를 내포) | +| `list_network_agents` | 관리 API | **제외** (admin 전용 global) | + +**Interface** (타입 교정 + response refilter 추가): ```rust -pub trait CloudDirectory: Send + Sync { - fn active_cloud(&self) -> String; - fn known_clouds(&self) -> Vec; - fn default_project(&self, cloud: &str) -> Option; // 신규 +use crate::port::types::ProjectScope; // 실제 타입 (Codex 교정) + +// 신규 pure fn (요청측) +pub(crate) fn build_list_networks_query(scope: &ProjectScope, filter: &NetworkListFilter) -> Vec<(String, String)> { ... } +pub(crate) fn build_list_security_groups_query(scope: &ProjectScope, filter: &SecurityGroupListFilter) -> Vec<(String, String)> { ... } +pub(crate) fn build_list_floating_ips_query(scope: &ProjectScope, filter: &FloatingIpListFilter) -> Vec<(String, String)> { ... } +pub(crate) fn build_list_subnets_query(scope: &ProjectScope, network_id: Option<&str>) -> Vec<(String, String)> { ... } + +// 신규 pure fn (응답측 defense-in-depth — Must-fix #6/7) +pub(crate) fn refilter_by_scope( + items: Vec, + active_project_id: &str, +) -> (Vec, usize /* dropped count */) { ... } + +// 기존 impl 내부에서 호출 +async fn list_security_groups(&self, filter, pag) -> Result<...> { + let scope = self.auth.get_token_info().await?.project; + let query = build_list_security_groups_query(&scope, filter); + let raw = paginated_list(&self.base, "/v2.0/security-groups", &query, ...).await?; + let (filtered, dropped) = refilter_by_scope(raw.items, &scope.id); + if dropped > 0 { + tracing::warn!(target: "cross-project-guard", + "[adapter_filter] dropped {} unscoped items in list_security_groups", dropped); + // 별도 CrossProjectBlockEvent(reason=adapter_filter)도 emit 가능 + } + Ok(PaginatedResponse { items: filtered, next: raw.next }) } ``` -**Dependencies**: 구현체 4개(ConfigCloudDirectory + 3 FakeClouds) 전수 업데이트. default 구현을 trait에 두지 않음 — 테스트 stub이 명시적으로 데이터를 넣도록 강제. -### ConfigCloudDirectory (impl 확장) -**Responsibility**: `Arc` 기반 `CloudDirectory` 실제 구현. +**Dependencies**: `ProjectScope` (실제 타입, `src/port/types.rs`), `NetworkListFilter` 등 기존 filter struct. `AuthProvider`. + +**Test strategy**: +- 각 pure fn에 대해 단위 테스트. assertion = "`query_pairs`에 `tenant_id={project_id}` 포함 + 기타 기존 filter 유지". +- **응답측**: `refilter_by_scope`에 cross-project 섞인 mock 데이터 주입 → 필터 후 active 리소스만 남는지 + dropped count == 기대값. (Codex Must-fix #6 충족) + +--- + +### C7. `NovaCinderAllTenantsPolicy` [확장] +**Responsibility**: nova/cinder list 엔드포인트의 `all_tenants` 플래그를 **기본 false(scope-only)** 로 강제. 필요 시 상위 호출부만 명시 opt-in. + +**Location**: `src/adapter/http/nova.rs`, `src/adapter/http/cinder.rs` + +**Affected endpoints**: +| Endpoint | 현재 상태 | 변경 | +|----------|----------|------| +| `nova.list_servers` | scope via token | `all_tenants=false` 명시, opt-in 파라미터 추가 | +| `nova.list_flavors` | public | 변경 없음 (flavor는 cross-project shared) | +| `nova.list_server_events(id)` | server 범위 | 변경 없음 | +| `cinder.list_volumes` | scope via token | `all_tenants=false` 명시 | +| `cinder.list_snapshots` | scope via token | `all_tenants=false` 명시 | +| `cinder.list_qos_specs`, `list_storage_pools` | 관리 API | **제외** (admin global) | + **Interface**: ```rust -impl CloudDirectory for ConfigCloudDirectory { - fn default_project(&self, cloud: &str) -> Option { - self.config.cloud(cloud).and_then(|c| c.default_project.clone()) - } - // 기존 active_cloud, known_clouds 그대로 -} +// 기존 signature 확장 — optional flag +async fn list_servers(&self, filter: &ServerListFilter, pag: &Pag, all_tenants: bool) -> Result<...>; +// 또는 Filter struct에 all_tenants 필드 추가 (deserialize로 default=false) ``` -**Dependencies**: -- `Config::cloud(&str) -> Option<&CloudConfig>` accessor 필요 (기존 미존재 시 추가). -- `CloudConfig::default_project` 필드 (D3 참조). -### ContextTargetResolver (확장) -**Responsibility**: Request → ContextTarget 변환 + disambiguation. -**Interface** (`resolve` 매칭 arm 추가): +**Dependencies**: 기존 filter struct, `TokenScope`는 token 자체에서 자연스럽게 scope 제공 (명시 주입 불필요 for nova/cinder — 토큰이 project-scoped이므로 OpenStack이 알아서 scope-limit). + +**Test strategy**: URL/query에 `all_tenants=true`가 들어가지 않는 default 케이스 + opt-in 시 들어가는 케이스 둘 다 단위 테스트. + +--- + +### C8. `FormSelectedIdValidator` [신규] +**Responsibility**: 폼 제출 시점에 선택된 리소스 ID(예: VolumeId, SecurityGroupId)의 project_id를 검증. 불일치 시 제출 거부 + 토스트. + +**Location**: `src/module/common/scope_validator.rs` (신규 common 모듈. 존재하지 않으면 생성) + +**Interface**: ```rust -ContextRequest::CloudOnly { cloud } => { - self.validate_cloud(&cloud)?; // NotFound(cloud) - let project = self.clouds.default_project(&cloud) - .ok_or(SwitchError::NotConfigured { cloud: cloud.clone() })?; - self.resolve(ContextRequest::ByName { // delegate - cloud: Some(cloud), - project, - domain: None, - }).await +pub struct FormValidationError { + pub field: String, + pub reason: CrossProjectReason, } + +pub fn validate_form_scope<'a>( + active: &str, + selections: impl Iterator, // (field_name, selected_project_id) +) -> Result<(), FormValidationError> { ... } ``` -**Dependencies**: -- `CloudDirectory::default_project` (신규 메서드). -- `SwitchError::NotConfigured` (신규 variant). -- 기존 `ByName` 경로 재사용 — disambiguation / not-found / ambiguous 모두 자동 상속. -### App::execute_command (확장) -**Responsibility**: 파싱된 `Command`를 app-level Action으로 변환. -**Interface** (src/app.rs:1771-1780 교체): +**Dependencies**: `CrossProjectGuard::check_form_selection` (C3). + +**Caller 수정 대상**: cinder CreateSnapshot form, server create form (SG/Network/FlavorKey 선택), network create form (subnet 선택) 등. **실제 수정 대상 리스트는 TDD 단계에서 form 내부 dropdown 카탈로그를 훑어 열거**. + +**Glance Mutation 경로 특수 처리 (Must-fix #3)**: FR1이 Glance를 제외하므로, Glance `DeleteImage` / `UpdateImage`는 origin-stamp만으로는 cross-target 공격(admin이 A scope에서 B의 image ID를 직접 지정해 삭제) 차단 불가. 구체 처리: +- `DeleteImage { id }` / `UpdateImage { id, .. }` 경로에서 **adapter pre-mutation GET으로 image `owner` 필드 취득** → active_scope와 비교 후 mismatch면 reject. +- Glance `owner`는 project_id에 해당. 기존 `glance.rs`의 `get_image`를 활용. +- 이 1개 adapter GET은 FR2의 일반 규칙(RTT 추가 회피)에 예외로 허용 — Glance의 scope 모델 특성상. +- Image module에서 form submit 전에 이 pre-check를 호출. + +**Test strategy**: 단일/복수 field 불일치 케이스. "선택 ID가 disambiguated option(`(proj: xxx)` 접미사)에서 온 cross-project인지" 시뮬레이션. + +--- + +### C9. `CrossProjectBlockEvent` [신규 — Council 개정] +**Responsibility**: FR5 스키마 structured event. fingerprint canonicalization + 스키마 필드 보강, tracing emit. + +**Location**: `src/infra/audit/cross_project_block.rs` (신규 `audit` 서브모듈) + +**Interface** (Council must-fix/should-consider 반영): ```rust -Command::SwitchCloud(name) => { - self.dispatch_action(Action::SwitchContext( - ContextRequest::CloudOnly { cloud: name }, - )); +pub struct CrossProjectBlockEvent { + // 기본 식별 + pub timestamp: DateTime, + pub actor_user_id: String, // **Keystone UUID (username 금지, Gemini 권고)** + pub actor_cloud: String, + + // 프로젝트 scope 정보 (Codex 권고: target field 의미 명확화) + pub active_project_id: String, + pub asserted_origin_project_id: Option, // FR2: action stamp 시점 scope. Option: form selection 시에는 None + pub target_project_id: Option, // 실제 target이 확인된 경우만 (FR4 form, FR1 adapter_filter) + + // Action / resource + pub action_type: String, // 예: "DeleteServer" + pub resource_kind: String, // 예: "server" + pub resource_id: Option, + + // 결정 + pub outcome: &'static str, // "blocked" + pub reason: String, // "origin_scope_mismatch" / "form_selection_mismatch" / "adapter_filter" / "role_tier" / "both" + pub guard_layer: &'static str, // **신규 (Codex should-consider)**: "fr2_worker" / "fr3_rbac" / "fr4_form" / "fr1_adapter" + pub correlation_id: u64, // **신규 (Codex should-consider)**: Epoch 값 — 다중 이벤트 연관 추적 + + // 무결성 + pub fingerprint: String, // v1 canonical sha256 앞 12자 (아래 규약) +} + +impl CrossProjectBlockEvent { + pub fn new( + reason: CrossProjectReason, + guard_layer: GuardLayer, + action: &Action, + context: &TokenInfo, + epoch: Epoch, + ) -> Self { ... } + + pub fn emit(&self); // tracing::warn! with target "cross-project-guard" } ``` -**Dependencies**: -- `Action::SwitchContext` (기존). -- `ContextRequest::CloudOnly` (신규 variant). -- stub toast 코드 제거. 기존 테스트 `test_command_bar_switch_cloud_emits_info_toast`는 dispatch 검증으로 대체. -### ContextSwitcher (확장) — FR-4 idempotent -**Responsibility**: 7-step switch orchestrator. FR-4 idempotent 체크를 resolver 직후에 삽입. -**Interface** (`switch` 메서드 내부 변경, signature 불변): +**Fingerprint canonicalization (Must-fix #5, Codex+Gemini 공통)**: +``` +input = "v1|" + + actor_user_id + "|" + + active_project_id + "|" + + asserted_origin_project_id.unwrap_or("") + "|" + + target_project_id.unwrap_or("") + "|" + + action_type + "|" + + resource_id.unwrap_or("") +fingerprint = sha256(input.as_bytes()).hex()[..12] +``` +- `v1|` 버전 prefix로 스키마 진화 시 구분 가능. +- `|` 구분자로 boundary ambiguity 방지 (e.g., "a" + "bc" vs "ab" + "c" 충돌 회피). +- `None` → 빈 문자열 명시 규칙. + +**Dependencies**: `CrossProjectReason` (C3), `GuardLayer` (C3 enum 확장), `Action` (for `action_type`/`resource_kind`/`resource_id` 추출), `Token` (for active project/actor), `Epoch` (from `src/context/epoch.rs`). + +**Best-effort 쓰기 (NFR1 정합)**: tracing이 로그 IO 실패해도 차단 자체는 이미 완료 상태. 이벤트 기록과 차단 행위는 독립 경로. + +**Test strategy**: +- 스키마 필드 모두 채워지는지 +- fingerprint 재현성(동일 input → 동일 fingerprint) + 경계 충돌 회피 검증 (e.g., `user="ab"` + `proj=""` vs `user="a"` + `proj="b"` → 다른 fingerprint) +- `reason` 매핑(enum → string) 정확성 +- `guard_layer` 네 값 모두 생성 가능한지 (각 FR 경로별) +- `correlation_id` = 현재 epoch 값으로 stamp되는지 + +--- + +### C10. `CrossProjectToast` [신규] +**Responsibility**: 차단 시 공통 한국어 카피 생성. `active_project_name` + `target_project_name`/`origin_project_name` 포함. 복붙 방지 위해 단일 모듈. Toast level = **Warning** 확정 (Gemini 권고 — 시스템 에러 vs 사용자 실수 구분). + +**Location**: `src/ui/cross_project_toast.rs` + +**Interface**: ```rust -pub async fn switch(&self, request: ContextRequest) - -> Result<(Epoch, ContextSnapshot), (Epoch, SwitchError)> -{ - let target = match self.resolver.resolve(request).await { ... }; - // [NEW] idempotent check - if let Some(current) = self.state.current_snapshot() - && current.target == target - { - return Ok((current.epoch, current)); - } - self.run_switch_to(target).await -} +pub fn build_origin_mismatch_toast(active_name: &str, origin_name: &str) -> Toast { ... } +pub fn build_form_mismatch_toast(active_name: &str, selected_name: &str, field: &str) -> Toast { ... } +pub fn build_glance_owner_mismatch_toast(active_name: &str, target_owner_name: &str) -> Toast { ... } // Glance 특수 ``` -**Dependencies**: -- `SwitchStateMachine::current_snapshot() -> Option` — 기존 존재 여부 확인 필요. 없으면 얕은 read-only accessor 신규 추가 (epoch/state_machine 내부 RwLock peek). -## 관측성 (NFR-6 반영) +**Toast 전달 동기성 (Gemini 권고)**: `CrossProjectToast`는 Action reject **직전** emit되어야 하며, 이후 발생할 수 있는 `ApiError` Toast보다 먼저 사용자에게 표시됨. Worker hook에서 toast_channel.send() → dispatch skip 순서 강제. + +**Dependencies**: 기존 `Toast` struct, `ToastLevel::Warning`. -| 위치 | 이벤트 | 필드 | -|---|---|---| -| `ContextTargetResolver::resolve(CloudOnly)` | `tracing::info_span!("resolve_cloud_only")` | `cloud`, `resolved_project` | -| `ContextSwitcher::switch` idempotent 분기 | `tracing::debug!("switch_noop_same_target")` | `cloud`, `project` | -| `SwitchError::NotConfigured` 발생 | `tracing::warn!("cloud_no_default_project")` | `cloud` | -| `App` toast 발행 | 기존 span 상속 | 추가 필드 없음 | +**Copy 예시**: +``` +"차단: '{active_name}' 프로젝트에서 '{origin_name}' 프로젝트의 리소스는 수정할 수 없습니다. + :switch-project {origin_name} 후 재시도하세요." +``` + +**Test strategy**: 포함 단어/치환자 + safe_display 규약(60자 truncate, 제어문자 제거) 준수. + +--- + +## 의존성 다이어그램 (ASCII — Council 개정) + +``` + [UI form / command / button] + | + | channel.send(Action) (55 call sites UNCHANGED) + v + +------------------------+ + | ActionSender C2 |<----- ScopeProvider (TokenInfo.project.id) + | (src/context/ | + | action_channel.rs) | + +------------------------+ + | + | stamp: DispatchedAction { action, origin_project_id } + | wrap: VersionedEvent + v + +------------------------+ + | mpsc channel | + +------------------------+ + | + v + +------------------------+ + | run_worker (C5 hook) |-----> action_to_kind(C1): Option + | (src/worker.rs:53) | | + +------------------------+ | if Some(_) → mutation path + | v + | +------------------------+ + | | CrossProjectGuard C3 | + | | check_origin_scope() | + | +------------------------+ + | | + | Allow | Block(reason) + | | | | + | +--------------+ | +----------------+ + | | | | + v v v v + proceed_dispatch(action) +----------------+ +-----------------+ + | CrossProject- | | CrossProject- | + | BlockEvent C9 | | Toast C10 | + | (tracing) | | (Warning) | + +----------------+ +-----------------+ + | + v + [Toast channel] + (ApiError보다 먼저) + + [Read 경로 — FR1] [Form submit 경로 — FR4] + | | + v v + +-------------------------+ +-------------------------+ + | NeutronListQueryBuilder | | FormSelectedIdValidator | + | + NovaCinder all_tenants| | C8 | + | C6/C7 (pure fn + refilt)| +-------------------------+ + +-------------------------+ | + | v + | CrossProjectGuard C3 + | dropped > 0 시: (check_form_selection) + v | + [tracing target="cross-project-guard" v + adapter_filter event] Glance mutation 경로: + adapter GET image.owner + → pre-mutation check + + [Token refresh event — FR3 방어] + | + v + +---------------------------+ + | RbacGuard | + | update_roles_preserve_*() | <-- project_id None-overwrite 방지 + +---------------------------+ +``` + +## Q1 확정: Adapter Endpoint 매트릭스 + +| Service | Endpoint | 현재 | 변경 방침 | 근거 | +|---------|----------|------|----------|------| +| Neutron | list_networks | `_filter` 무시 | `tenant_id` 주입 | SG와 동일 leak 표면 | +| Neutron | list_security_groups | `_filter` 무시 | `tenant_id` 주입 | 4겹 버그 원인 | +| Neutron | list_floating_ips | `_filter` 무시 | `tenant_id` 주입 | 동일 | +| Neutron | list_subnets | minimal filter | `tenant_id` 주입 | 네트워크 계열 일관성 | +| Neutron | list_ports | device_id 범위 | 변경 없음 | device_id가 scope 내포 | +| Neutron | list_network_agents | admin API | 제외 | admin-only global listing | +| Nova | list_servers | token scope | `all_tenants=false` 명시 | 현재도 사실상 scope-only, 문서화 | +| Nova | list_flavors | public | 변경 없음 | flavor는 tenant-agnostic shared | +| Nova | list_server_events(id) | server 범위 | 변경 없음 | id가 scope 내포 | +| Nova | list_server_migrations(id) | server 범위 | 변경 없음 | 동일 | +| Nova | list_aggregates / compute_services / hypervisors | admin API | 제외 | admin-only global | +| Cinder | list_volumes | token scope | `all_tenants=false` 명시 | scope-only 재확인 | +| Cinder | list_snapshots | token scope | `all_tenants=false` 명시 | 동일 | +| Cinder | list_qos_specs / storage_pools | admin API | 제외 | admin-only global | +| Glance | list_images | visibility mixed | **이번 범위 제외** | public/shared 이미지 정합 처리가 복잡, 후속 BL | +| Keystone | list_projects/users/roles/domains | admin API | 제외 | 명시적 cross-project 관리 API | + +**"제외" 처리된 endpoint는 FR2(worker origin-scope guard) + FR3(RBAC role-tier)로 보호**. 읽기 레벨에서 cross-project 데이터가 보일 수는 있지만, mutation은 여전히 차단됨. Glance는 별도 BL로 분리(visibility 모델이 별개 이슈). + +## Q3 확정: Worker 거부 에러 variant + +**결정**: 기존 `AppError`(`#[non_exhaustive]`)에 `CrossProjectBlocked` variant 추가. + +```rust +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum AppError { + // ... 기존 variants ... + + #[error("Cross-project action blocked: origin '{origin}' != active '{active}' (reason: {reason:?})")] + CrossProjectBlocked { + origin: String, + active: String, + reason: CrossProjectReason, + }, +} +``` +`CrossProjectReason`은 `CrossProjectGuard` 모듈에서 정의하고 `src/error.rs`에서 re-export. 순환 의존 회피. + +## Assumption 재확정 (DETAIL 기반) + +| Assumption | 상태 | Notes | +|-----------|------|------| +| A1 active_scope 전파 | ✅ 확정 | 실제 경로: `AuthProvider::get_token_info().project.id` (ProjectScope{id, name, domain}). `RbacGuard::project_id()`는 cache된 copy. Worker는 전자를 1차 소스로. | +| A2 mock HTTP 인프라 | ✅ 회피 확정 | pure fn URL/query 빌더 추출. C6/C7에서 구체화. | +| A3 `build_disambiguated_opts` 순수성 | ✅ 확정 | 이번 BL에선 확장 없이 유지. | + +## DETAIL 단계에서 남은 결정(TDD RED에서 확정) + +- `FormSelectedIdValidator` 정확한 위치 (`src/module/common/` 존재 여부에 따라) — 없으면 `src/ui/form_scope_validator.rs` +- **Toast level = Warning (확정, Gemini 권고 반영)** +- `DispatchedAction.origin_project_id`: read-only는 `None`으로 확정 (mutation만 `Some(scope)`). 가드 skip 시맨틱이 None 존재로 명시화. +- `ScopeProvider` trait의 1차 소스: `Token.project.id` (AuthProvider) vs `RbacGuard.project_id()` cache. **권고**: primary = Token, fallback = RbacGuard cache. RED에서 검증. +- C4-bis `update_roles_preserve_project()` interface 선택 (preserve API vs Option>) — RED에서 확정 + +## Council Review Response (Must-fix → 본 문서 반영 매핑) + +| Must-fix | 반영 위치 | 상태 | +|---------|-----------|------| +| #1 StampedAction 폐기 → ActionSender 중앙 스탬핑 | C2 재설계, 다이어그램 개정 | ✅ | +| #2 Type ref 교정 (TokenScope/ScopedAuthSession → Token.project.id/RbacGuard.project_id) | Assumption Verification 표, C6 interface | ✅ | +| #3 Glance DeleteImage/UpdateImage scope 검증 | C8 Glance 특수 처리 섹션 | ✅ | +| #4 Glance = FR4 adapter pre-mutation GET | C8 Glance pre-check | ✅ | +| #5 Fingerprint canonicalization (v1 prefix + `\|` delimiter + None 규칙) | C9 Interface + Fingerprint 규약 | ✅ | +| #6 Token refresh project_id 보존 | C4-bis 신규 | ✅ | +| #7 FR1 response-side defense-in-depth (client-side re-filter) | C6 Interface (`refilter_by_scope`) | ✅ | +| #8 is_mutation / action_to_kind parity | C1 보강 (기존 action_to_kind 재사용 + parity 테스트) | ✅ | + +| Should-consider | 반영 위치 | 상태 | +|-----------------|-----------|------| +| Schema `guard_layer` + `correlation_id` 추가 | C9 Interface 확장 | ✅ | +| `target_project_id` rename → asserted_origin/target 분리 | C9 Interface 개정 | ✅ | +| Toast level = Warning 확정 | C10 | ✅ | +| user_id = Keystone UUID | C9 field 주석 | ✅ | +| Toast 동기성 (ApiError 앞) | C10 설명 추가, C5 pseudo-flow 순서 명시 | ✅ | +| Background task Action emit audit | C5 Test strategy 주석 (TDD 단계 audit) | 🟡 (구현 시 확인) | +| End-to-end mock integration 1개 merge-blocking | NFR2 — 다음 섹션 보강 필요 | 🟡 (code-generation에서 추가) | + +Future-BL 5건은 backlog에 따로 기록 (시스템-summary Next Steps 참조). diff --git a/devflow-docs/inception/design-review-raw/codex.md b/devflow-docs/inception/design-review-raw/codex.md new file mode 100644 index 0000000..e24f6a0 --- /dev/null +++ b/devflow-docs/inception/design-review-raw/codex.md @@ -0,0 +1,96 @@ +### A. Semantic correctness of FR2 (target→origin shift) + +1. **What is correct:** 2b is a valid minimal fix for TOCTOU/cache-stale submission after scope switch. That threat is clearly captured. +Refs: [requirements.md:34](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:34), [requirements.md:37](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:37), [application-design.md:60](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:60) + +2. **Main gap:** origin-match does **not** prove target resource belongs to active project. If user never switches (origin==active), cross-project target can still pass. +Refs: [requirements.md:41](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:41), [action.rs:6](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/action.rs:6) + +3. **Concrete slip scenario:** Glance is excluded from FR1, list can be broad, and delete is UI-available for admin; FR2 origin check won’t block same-origin submission. +Refs: [application-design.md:461](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:461), [application-design.md:464](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:464), [glance.rs:84](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/adapter/http/glance.rs:84), [image/mod.rs:124](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/module/image/mod.rs:124) + +### B. `StampedAction` wrapper maintainability + +1. Wrapping ~55 dispatch sites is feasible but high regression surface; this project already has one centralized envelope (`VersionedEvent`), so another envelope at send sites increases churn. +Refs: [application-design.md:123](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:123), [action_channel.rs:24](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/context/action_channel.rs:24) + +2. Single stamped channel is the better of the two options (avoids split pipeline bugs). +Ref: [application-design.md:126](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:126) + +3. `is_mutation()` exhaustive match is good, but it must stay in lockstep with existing `action_to_kind()` mapping to avoid policy drift. +Refs: [application-design.md:69](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:69), [worker.rs:151](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/worker.rs:151) + +4. Simpler alternative: stamp once at `ActionSender` boundary (centralized), not 55 call sites, while preserving TOCTOU defense. + +### C. Threat model gaps (security UX lens) + +1. **Replay/in-flight action gap:** worker currently does not epoch-drop stale actions; it uses epoch only to stamp outgoing events. Side effects can still execute. +Refs: [worker.rs:63](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/worker.rs:63), [worker.rs:107](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/worker.rs:107), [app.rs:600](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/app.rs:600), [action_channel.rs:31](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/context/action_channel.rs:31) + +2. **Switch race:** mid-switch dispatch is blocked (good), but already spawned worker tasks are not tied to `CancellationRegistry`; long-running operations can continue. +Refs: [app.rs:562](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/app.rs:562), [worker.rs:100](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/worker.rs:100), [switcher.rs:152](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/context/switcher.rs:152) + +3. **Token-refresh boundary:** FR3 relying on cached `RbacGuard.project_id` is fragile; refresh event carries roles only and app writes `project_id=None`. +Refs: [event.rs:178](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/event.rs:178), [app.rs:617](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/app.rs:617), [rbac.rs:113](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/infra/rbac.rs:113) + +4. Toast timing is mostly okay, but there is a UX race with pre-dispatch progress toast and later block toast. + +### D. Audit event schema + PII trade-off + +1. Schema is strong for baseline forensics (who/where/what/why/outcome). +Refs: [requirements.md:58](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:58), [application-design.md:335](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:335) + +2. Missing: `guard_layer` (FR2/FR3/FR4), and an epoch/correlation field for multi-event reconstruction. + +3. `target_project_id` is semantically ambiguous under FR2-origin mismatch (often it is actually origin, not true target). + +4. Fingerprint concat needs canonicalization (length-prefix or canonical JSON). Plain concat risks boundary ambiguity collisions. +Ref: [application-design.md:357](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:357) + +5. Plaintext `actor_user_id` is acceptable for internal admin tooling if retention/access is documented; hashing can be deferred. + +### E. Glance visibility exclusion + +Not safe to defer as currently framed. Excluding Glance from FR1 while relying on FR2/FR3 leaves a practical mutation surface (`DeleteImage`) inconsistent with the stated policy (“no cross-project mutate/delete”). +Refs: [requirements.md:17](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:17), [application-design.md:461](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:461), [image/mod.rs:124](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/module/image/mod.rs:124) + +### F. Assumption verification rigor (A1/A2/A3) + +1. **A1 is not tight yet.** The doc conflates scope types: `TokenScope` has no project_id, and `ScopedAuthSession` has no project fields. +Refs: [application-design.md:12](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:12), [types.rs:42](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/port/types.rs:42), [scoped_session.rs:33](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/adapter/auth/scoped_session.rs:33) + +2. **A2 is directionally reasonable** (no mock infra). But FR1 response-side acceptance is not covered by query-builder-only tests. +Refs: [Cargo.toml:38](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/Cargo.toml:38), [requirements.md:30](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:30), [application-design.md:270](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/application-design.md:270) + +3. **A3 verification is solid.** `build_disambiguated_opts` is pure and locally contained. +Ref: [server/mod.rs:32](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/module/server/mod.rs:32) + +### G. Rust idiomaticity and test strategy + +1. Pure URL builders are idiomatic and useful. Since `paginated_list` only concatenates `path?query`, fidelity is decent for request-shape tests. +Ref: [http/mod.rs:97](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/src/adapter/http/mod.rs:97) + +2. But they do not prove response-side scope filtering or endpoint-policy correctness (required by FR1). +Ref: [requirements.md:30](/Users/jay.ahn/projects/infra/nexttui/.worktrees/bl-p2-085-cross-project-scoping/devflow-docs/inception/requirements.md:30) + +3. `CrossProjectGuard` as free functions is idiomatic here (policy is pure, stateless). Trait abstraction is unnecessary unless multiple policy backends are planned. + +4. T1 mock-only merge-blocking is risky for a P0 boundary; keep it, but add at least one automated high-risk integration/contract test path. + +### H. Risk log + action items + +**Must-fix (blocking this BL)** +- FR1: Add response-side cross-project filtering/assertions, not query-only tests. +- FR2/FR3: Close same-origin foreign-target mutation gap, especially Glance delete path. +- Worker: Enforce stale-action drop/cancel before side effects, not event-drop only. +- FR5/C9: Canonicalize fingerprint input (delimiter/versioned schema) before hashing. + +**Should-consider (strongly recommended)** +- C1/worker: Add parity tests between `is_mutation()` and `action_to_kind()`. +- C2: Centralize stamping in `ActionSender` to reduce 55-site regression risk. +- C9: Add `guard_layer` and `epoch/correlation_id` to audit schema. + +**Future-BL (defer with justification)** +- FR5: Configurable user-id hashing mode for stricter privacy deployments. +- FR1/Glance: Full visibility/owner policy matrix and adapter-level normalization. +- Test infra: Introduce HTTP mock server harness for end-to-end request assertions. diff --git a/devflow-docs/inception/design-review-raw/gemini.md b/devflow-docs/inception/design-review-raw/gemini.md new file mode 100644 index 0000000..b354cb6 --- /dev/null +++ b/devflow-docs/inception/design-review-raw/gemini.md @@ -0,0 +1,58 @@ +I will start by reading the primary design document and the associated requirements to understand the proposed structural fix for the cross-project scoping bug. +I will now examine the `Action` enum in `src/action.rs` and the `run_worker` function in `src/worker.rs` to understand the current implementation and evaluate the impact of the proposed `StampedAction` wrapper. +I will now examine `src/infra/rbac.rs` to understand the `ActionKind` enum and `RbacGuard` struct, which will help me evaluate the proposed project-scope guard integration. +I will now examine `src/port/auth.rs` and `src/port/types.rs` to understand the token and project definitions, which will clarify how `active_scope` is handled in the authentication layer. +I will search for `TokenScopeFingerprint` and examine `src/adapter/auth/scoped_session.rs` to understand how the `ScopedAuthSession` handles the active scope and its fingerprinting. +I will now examine `src/adapter/http/neutron.rs` to understand how the filtering logic is currently implemented and evaluate the feasibility of injecting the `tenant_id` query parameter as proposed. +I will now search for the `list_security_groups` implementation in `src/adapter/http/neutron.rs` to understand how it currently handles requests and filtering. +I will now read the implementation of `list_security_groups` in `src/adapter/http/neutron.rs` starting from line 342. +I will now search for the implementation of `build_security_group_query` in `src/adapter/http/neutron.rs` to see how it currently constructs the query parameters. +This design review addresses the **BL-P2-085 Cross-Project Scoping** structural fix. The proposed plan is a comprehensive and pragmatic approach to a P0 security boundary bug, balancing rigorous protection with the reality of a large existing codebase. + +### A. Semantic correctness of FR2 (target→origin shift) +The shift from "target-matching" to "origin-matching" (`StampedAction`) is a clever and effective solution for the TUI's stateful environment. +* **Success**: It perfectly addresses the **TOCTOU** (Time-of-Check to Time-of-Use) vulnerability where a user switches projects while a mutation form is open. +* **Risk**: It relies on the assumption that the `resource_id` within the `Action` belongs to the `origin_project_id`. While true for UI-driven workflows (where IDs are discovered via scoped list calls), it doesn't prevent a "Project A" action from targeting "Project B" resource if the user stays in Project A. +* **Mitigation**: This is adequately covered by **FR1** (adapter-level scoping) and **FR4** (form validation), which ensure that the IDs available to the user are already project-scoped. + +### B. `StampedAction` wrapper maintainability +The proposal to wrap 55 dispatch sites manually is the highest regression risk in the plan. +* **Gap**: Manual wrapping at each `channel.send` call site is prone to omission. +* **Improvement**: Centralize stamping by providing a `dispatch` helper on the application context (e.g., `app_ctx.dispatch(action)`). This helper would handle the `StampedAction` wrapping internally using the current active scope, reducing the diff surface and preventing "unstamped" mutations from slipping through. + +### C. Threat model gaps +* **Background Tasks**: Background refresh/polling tasks (e.g., `poll_migration_progress` in `src/worker.rs:109`) should be explicitly audited. If these tasks trigger mutations (like auto-cleaning stale resources), they must carry a valid stamp or be exempt via `is_mutation() == false`. +* **Toast Delivery**: The design should ensure that the `CrossProjectToast` is sent *before* any `AppEvent::ApiError` resulting from a blocked action to avoid user confusion. + +### D. Audit event schema + PII trade-off +The structured audit event schema is excellent for forensics. +* **PII**: Using plaintext `actor_user_id` is acceptable for internal KT Cloud tools. However, ensure it uses the **Keystone UUID** rather than the username to maintain audit trail integrity across username changes. +* **Fingerprint**: Concatenating fields for the SHA256 fingerprint is sound, but ensure the `resource_id` is canonicalized (e.g., empty string if `None`) to prevent fingerprint jitter. + +### E. Glance visibility exclusion +Excluding Glance from **FR1** (List filtering) is a justified trade-off due to the complexity of `public` and `shared` visibility models. +* **Safety check**: Ensure that Glance mutation variants (`DeleteImage`, `UpdateImage`) are strictly categorized as `is_mutation() == true` in **C1** so that **FR2** (Worker guard) still protects them. + +### F. Assumption verification rigor (A1/A2/A3) +* **A2 (Pure fn workaround)**: Extracting URL/query builders into pure functions for unit testing is a highly idiomatic Rust solution to the lack of wiremock/mockito. It provides high confidence in `tenant_id` injection without polluting the dependency graph. +* **A1 (Active scope propagation)**: The verification that `RbacGuard` and `AuthProvider` already hold `project_id` confirms that the structural foundation for this fix is already in place. + +### G. Rust idiomaticity and test strategy +* **Idiomaticity**: `CrossProjectGuard` might be more ergonomic if implemented as a trait or an `impl` on `StampedAction` rather than a set of free functions. +* **Test Strategy**: The **T1 (mock unit only)** strategy is appropriate for a structural fix of this scale. However, ensure the `Action::is_mutation()` match block is `#[deny(non_exhaustive_omitted_patterns)]` or equivalent to prevent silent failure when adding new variants. + +### H. Risk log + action items + +#### **Must-fix** (Blocking this BL) +1. **C1 (ActionKind)**: Use a `match` with no default (`_`) in `is_mutation()` to ensure every new `Action` variant is explicitly categorized. +2. **C2 (Dispatch)**: Provide a centralized `dispatch_action(action)` helper to automate `StampedAction` creation and minimize churn across 55+ sites. +3. **C6 (Neutron)**: Verify that `list_subnets` correctly handles the `network_id` filter alongside the new `tenant_id` filter (AND logic). + +#### **Should-consider** (Strongly recommended) +1. **Audit**: Use Keystone UUID for `actor_user_id` instead of username for stable auditing. +2. **UX**: Set `CrossProjectToast` level to `Warning` to distinguish user-originated scope errors from system API failures. +3. **Design**: Implement `CrossProjectGuard` as a trait to improve discoverability within the codebase. + +#### **Future-BL** (Defer with justification) +1. **Glance Scoping**: Comprehensive filtering for `public`/`shared` images. (Complex visibility logic deserves a dedicated BL). +2. **Audit Dashboard**: A UI for viewing `CrossProjectBlockEvent` logs. (Current scope is focused on protection/logging). diff --git a/devflow-docs/inception/design-review-raw/synthesis.md b/devflow-docs/inception/design-review-raw/synthesis.md new file mode 100644 index 0000000..e7b5b12 --- /dev/null +++ b/devflow-docs/inception/design-review-raw/synthesis.md @@ -0,0 +1,96 @@ +# Council Synthesis — BL-P2-085 application-design + +**Timestamp**: 2026-04-24T13:30:00+09:00 +**Council Mode**: FULL (Codex 0.121.0 + Gemini 2.5-pro + Claude 의장) +**Raw**: `codex.md`, `gemini.md` 동일 디렉토리 + +## 충돌 해결 4단계 적용 + +### 1. 공통 의견 (두 AI 모두 지적 → 즉시 채택) + +| 항목 | Codex 표현 | Gemini 표현 | 조치 | +|------|-----------|------------|------| +| 중앙 스탬핑 | "Stamp once at `ActionSender` boundary" | "Centralize stamping — `app_ctx.dispatch()` helper" | 55 site 수정 폐기, **기존 `ActionSender` + `VersionedEvent`에 origin 필드 추가**하는 방식으로 전환 | +| `is_mutation()` exhaustive 보장 | "Must stay in lockstep with `action_to_kind()`" | "Use match with no default (`_`)" | exhaustive match + 기존 `worker.rs:151 action_to_kind()`과의 parity 테스트 추가 | +| Fingerprint canonicalization | "Canonicalize (length-prefix or JSON)" | "Canonicalize `resource_id` (empty string if `None`)" | 단순 concat 대신 delimiter + Option 명시 규칙 확정 | +| Glance `DeleteImage` 보호 | "Not safe to defer… mutation surface inconsistent" | "Ensure `is_mutation() == true`" | **FR2(origin-guard) + FR4(form validator)로 확실히 커버**. Codex는 더 엄격 요구 — 아래 Section 2 참조 | +| PII 허용 가능 | "Acceptable if retention documented" | "Use Keystone UUID not username" | 평문 허용 + Keystone **UUID 사용 명시** (username 회피) | + +### 2. 상충 의견 (근거 비교 후 판정) + +| 논점 | Codex 입장 | Gemini 입장 | 의장 판정 | +|------|-----------|------------|----------| +| **Glance 제외의 안전성** | 🔴 "Not safe to defer" — 동일 origin 상황에서 cross-target 차단 안 됨 | 🟡 "justified trade-off, `is_mutation()=true`면 worker guard가 커버" | **Codex 지지**. Gemini는 FR2가 origin-match이므로 "동일 origin에서 cross-target 대상 mutation"을 FR2가 잡지 못함을 간과. 구체 시나리오: admin이 project A에 머문 채 Glance 전역 리스트에서 project B 이미지 ID 확보 → DeleteImage(B_id) 발행 시 origin=A=active=A → FR2 통과, Glance 서버가 admin 토큰으로 실행 → B 이미지 삭제. **Must-fix**: Glance DeleteImage/UpdateImage에 대해서만이라도 **form 검증(FR4) 또는 adapter pre-mutation scope check** 추가. 전면 FR1 Glance 편입은 복잡(visibility 모델)해도, **DeleteImage 한 경로는 scope 검증 의무화** 가능. | +| **CrossProjectGuard trait vs free fn** | "Free fn 충분, trait 불필요 (정책 하나뿐)" | "Trait이 discoverability에 유리" | **Codex 지지**. 현재 정책 backend 1개뿐이고 trait은 premature abstraction. free fn으로 유지. | +| **T1 mock-only 적절성** | "Risky for P0, 최소 1개 integration path 추가" | "Structural fix 규모에 적절" | **Codex 부분 지지**. 현재 CI에 `devstack-integration` placeholder가 BL-P2-081 우선권 보유라 활성화는 피해야 함. 그러나 **mock 기반 "end-to-end form→worker→adapter" 하나는 merge-blocking으로 추가**할 수 있음 — 이는 진짜 integration이 아닌 Rust 내 adapter/worker/form 3축 연결 테스트. Should-consider. | + +### 3. 단독 의견 (한 AI만 제기) + +#### Codex 단독 발견 — 모두 근거 강함 + +| 항목 | 평가 | 조치 | +|------|------|------| +| **FR2 동일 origin cross-target gap** (Section A) | ✅ 정당. origin-match는 "폼 open 후 scope 전환"은 잡지만 "동일 scope에서 타 project 리소스 ID로 직접 mutation"은 잡지 못함. | 위 Glance 논의로 이미 커버. FR4 form validation + (선택적) adapter pre-mutation 확인으로 보강. **Must-fix (scope 제한적)**. | +| **A1 type reference가 틀렸음** | ✅ 정당. 실측 재확인: `TokenScope` (port/types.rs:42)은 enum `Project { name, domain }` — id 없음. `ScopedAuthSession` (scoped_session.rs:33)는 struct에 project field 자체 없음. 실제 project_id 소스는 `Token.project.id` (ProjectScope at port/types.rs) 또는 cached `RbacGuard.project_id`. | **Must-fix**: application-design.md 타입 레퍼런스 교정. | +| **기존 `ActionSender` + `VersionedEvent` envelope 존재** | ✅ 정당. `src/context/action_channel.rs`에 이미 ActionSender가 있음. StampedAction은 두 번째 envelope. | **Must-fix (설계 전환)**: StampedAction 래퍼 **폐기**. 대신 `VersionedEvent`에 `origin_project_id` 필드 추가하거나, ActionSender::send에서 current scope를 함께 stamp. 55 call site 수정 → 5 파일 수정으로 축소. | +| **Worker 스탈 action drop 부재** | 🟡 부분 정당. epoch 기반 drop은 BL 스코프 밖 pre-existing 이슈. 다만 origin-stamp가 있으면 "스탈 action이 새 scope에서 실행 시도"는 차단됨. 완전 커버는 아니나 BL 범위에서는 충분. | Should-consider. 별도 개선 BL로 분리 고려 (BL-P2-086?). | +| **Token refresh path: project_id=None 덮어쓰기** | ✅ 정당. `RbacGuard::update_roles(roles, project_id: Option)` 호출 시 refresh path가 `None`을 전달하면 FR3 scope check가 깨짐. | **Must-fix**: refresh 이벤트에서 project_id를 유지(또는 명시적 읽기) 확인 + RbacGuard가 None으로 overwrite되지 않도록 방어. | +| **FR1 response-side assertion 부재** | ✅ 정당. 현재 pure fn URL 빌더 테스트만으로는 "응답 측 필터링 효과"를 검증하지 못함. requirements.md FR1 수용 기준 (b)와의 gap. | **Must-fix**: mock HTTP 없이 가능한 형태로 — adapter layer에서 response가 오면 **client-side re-filter**(방어심층)를 추가하고, 그 re-filter를 단위 테스트. | +| **Missing schema fields: `guard_layer`, `epoch/correlation_id`** | ✅ 정당. 이벤트 재구성/원인 추적에 도움. | **Should-consider**: FR5 스키마에 `guard_layer` (fr1/fr2/fr3/fr4) + `correlation_id` (u64 epoch 또는 uuid) 추가. | +| **`target_project_id` 의미 모호 (origin-match 하에서)** | ✅ 정당. FR2가 origin-match이면 event field 이름이 혼란을 줌. | **Should-consider**: 필드 rename — `target_project_id` → `asserted_origin_project_id` 또는 `blocked_action_origin_project_id`. 실제 target이 확인된 경우만 `target_project_id` 남기기. | + +#### Gemini 단독 발견 + +| 항목 | 평가 | 조치 | +|------|------|------| +| **Background polling task mutations** (`poll_migration_progress` 등) | ✅ 정당. | **Should-consider**: worker.rs 내 background task가 Action emit하는 경로에 대해 `is_mutation()=true`인 것은 반드시 현재 scope 기준 stamp 필요. code-generation 단계에서 명시. | +| **Toast 전달 순서** (ApiError보다 먼저) | ✅ 정당. UX 혼란 방지. | **Should-consider**: code-generation 단계에서 Toast emit을 Action reject와 동기 경로로. | +| **Toast level = Warning** (현재 Error 예비 결정) | ✅ 정당. 시스템 에러와 구분 필요. | **Should-consider**: 확정 — Warning level. | + +### 4. 최종 Action Item 리스트 + +#### Must-Fix (blocking this BL) + +1. **설계 전환**: `StampedAction` 래퍼 폐기 → 기존 `ActionSender` + `VersionedEvent` envelope에 `origin_project_id` 스탬프 통합. (C2 → 재설계, diff 55 site → ~5 파일) +2. **Type reference 교정**: application-design.md의 `TokenScope` / `ScopedAuthSession` 언급을 실제 타입으로 수정 — active_scope는 `Token.project.id` (port/types.rs ProjectScope) 또는 cached `RbacGuard.project_id`에서 온다. +3. **`is_mutation()` parity**: 기존 `worker.rs:151 action_to_kind()` 매핑과의 parity 테스트 강제. 두 exhaustive 매치가 일치하지 않으면 컴파일 실패. +4. **Glance DeleteImage/UpdateImage scope 검증**: FR4 form validator가 Glance 이미지 ID의 owner project_id를 선택 시점에 검증. FR1 전면 Glance 편입 대신 mutation path에만 scope 의무화 (scope 제한적 fix). +5. **Fingerprint canonicalization**: sha256 input에 명시적 delimiter 포함 (예: `"user|active|target|action|resource"`) + None은 빈 문자열로 명시. version prefix(`"v1:"`) 권장. +6. **Token refresh path의 project_id 보존**: refresh 이벤트가 `RbacGuard::update_roles(roles, None)`로 project_id를 덮지 않도록 방어. 명시적 `update_roles(roles, retain_project_id: bool)` 또는 별도 메서드 분리. +7. **FR1 response-side defense-in-depth**: adapter layer에서 응답 parsing 후 **client-side re-filter**로 scope 불일치 리소스 제거 + 단위 테스트. mock HTTP 없이 pure fn 기반 검증 가능. + +#### Should-Consider (강력 권고) + +1. **Audit schema 필드 추가**: `guard_layer`(fr1/fr2/fr3/fr4) + `correlation_id` (u64 epoch 또는 uuid). +2. **`target_project_id` 필드 rename**: origin-match 하에서 의미 모호 해소. `asserted_origin_project_id` 등. +3. **End-to-end mock integration test 1개**: form→ActionSender→worker→adapter 연쇄를 단일 시나리오로 merge-blocking. 기존 1370 tests 위에 1-2개 추가. +4. **Background task Action emit 경로 audit**: `poll_migration_progress` 등에서 mutation emit 시 현재 scope stamp 강제. +5. **Toast level = Warning** (예비 결정 확정). +6. **Toast emit 동기성**: Action reject와 Toast send가 동일 경로에서 (ApiError보다 먼저) 발행되도록 code-generation에서 확인. +7. **Actor user_id = Keystone UUID** (username 금지) 명시. + +#### Future-BL (정당한 deferral) + +1. **Glance visibility 전면 정합**: public/shared 이미지 scope 모델 전면 검토. 별도 BL 권고. (Codex 지지) +2. **Epoch 기반 worker stale-action drop**: cancellation hygiene 개선. BL-P2-086 후보. (Codex 원래 제기, BL 범위 밖) +3. **PII user_id 해싱 옵션**: 조직별 privacy 수준에 따라. 후속 옵션. +4. **HTTP mock server 도입 (mockito/wiremock)**: BL-P2-081 (dual-strategy cross-cloud auth) 착수 시 자연스럽게. +5. **Audit 뷰어 / 패턴 대시보드**: 스키마는 이번에 픽스됨. + +### 합의된 설계 변경 요약 + +| 원래 (DETAIL) | 변경 후 | +|--------------|---------| +| `StampedAction { action, origin_project_id }` wrapper 신규 | **폐기**. `VersionedEvent` envelope에 `origin_project_id` 필드 추가. | +| 55 dispatch site 래핑 | `ActionSender::send()` 1곳에서 중앙 stamp | +| A1 type ref: `TokenScope`, `ScopedAuthSession` | `Token.project: ProjectScope{id,name,...}` + `RbacGuard::project_id()` (cached) | +| `target_project_id` (event field) | `asserted_origin_project_id` 또는 rename (origin-match 의미 명확) | +| Glance 전면 제외 (FR1+FR2+FR3) | FR1 제외 유지 + **Glance mutation path에 FR4 scope validation 의무화** | +| FR1 수용 기준 "응답측" = pure fn URL 빌더만 | 추가로 **adapter client-side re-filter** (defense-in-depth) 단위 테스트 | +| `is_mutation()` 단독 exhaustive match | `is_mutation()` + `action_to_kind()` parity 테스트 pair | +| Fingerprint = 단순 concat | version-prefixed + delimiter + Option 명시 규칙 | +| Refresh event project_id=None 덮어쓰기 | RbacGuard project_id 보존 가드 추가 | + +### 리뷰 품질 평가 + +Codex가 실제 코드베이스에 깊숙이 들어가 existing infrastructure(`ActionSender`, `action_to_kind`)을 발견해 중복 설계를 방지. Gemini가 UX/운영자 관점에서 보조 제언. 두 리뷰 상호 보완. **Council Full 선택이 정당화됨** — single review(Claude만)였다면 ActionSender 중복 / A1 type error를 놓칠 가능성 실재. diff --git a/devflow-docs/inception/requirements-review-raw/adversarial.md b/devflow-docs/inception/requirements-review-raw/adversarial.md deleted file mode 100644 index ac4eef1..0000000 --- a/devflow-docs/inception/requirements-review-raw/adversarial.md +++ /dev/null @@ -1,35 +0,0 @@ -# Adversarial Review — BL-P2-074 requirements.md - -**Reviewer**: general-purpose (Codex substitute — Codex agent permission denied) -**Date**: 2026-04-18T23:00+09:00 - -## HIGH - -### H1. "default project = `CloudConfig.auth.project_name`" 의미적 오류 -`auth.project_name`은 Keystone bootstrap credential의 scope이지 "사용자가 해당 cloud에서 선호하는 working project"가 아니다. 공유 `clouds.yaml`에서 많은 환경은 `admin`/`service`로 고정하는데, 이를 switch-cloud 기본값으로 재사용하면 운영 cloud로 전환하자마자 실수로 admin scope에 떨어진다 — OWASP A01 계열 privilege 오남용 표면. `--cloud` CLI(PR#55) precedent 재사용은 "동일 소스"라는 이점보다 "초기 bootstrap ≠ 사용자 선호"라는 의미 충돌이 크다. Assumption 1이 이를 "단일 소스 원칙"으로 포장하는 것은 근거 빈약. **최소한 FR-3에 "last-used project per cloud"를 fallback으로 덧붙일 것**을 요구. - -### H2. 옵션 (b) 거절 근거 빈약 -Unit 6 ContextPicker 의존은 scope creep의 증거가 아니라 **순서 문제**. 현재 toast가 이미 "(picker: Ctrl+P — Unit 6)"로 사용자에게 약속한 상황에서 picker 없이 "완결" 선언은 user contract 위반. (a) 채택 시 picker 도입 후 CloudOnly 경로는 "explicit choice 레이어 아래 잔재"가 되어 두 코드 경로를 유지해야 한다. "(b)를 블록하지 않음"은 true지만 **왜 지금 picker를 못 짓는가**에 대한 비용 근거가 없다. - -### H3. FR-5 에러 메시지 vs BL-P2-053 SwitchError 확장 debt -"NotConfigured를 문자열로 우회" 선택은 BL-P2-053 착수 시 **테스트와 사용자 facing message 양쪽에서 breaking migration**이 된다. FR-7이 문자열 substring 어써션을 쓰면 053에서 깨질 것 — 지금 `#[non_exhaustive]` 전용 variant 하나 추가가 더 싸다. - -## MEDIUM - -### M1. FR-4 no-op race -"이미 활성 project와 동일"을 **resolver 출력 시점**에 비교하는데, state_machine이 `Switching` 상태면 "활성"이 pre-switch인지 pending인지 모호. epoch bump와의 교차 정의 누락. Assumption 5가 "switcher가 담당"으로 미루지만 SwitchContextSwitcher가 CloudOnly 경로에서 동일 idempotency를 보장한다는 증거 없음. - -### M2. NFR-3 "100~150 lines" 과소 추정 -`ContextRequest`는 `#[non_exhaustive]`가 **없고** `PartialEq/Eq` 파생으로 구조적 매칭 전제. codebase 내 모든 `match request`/`ContextRequest::`을 grep 기반으로 열거하지 않은 채 추정. 테스트 fakes + `CloudDirectory` trait 확장 호출부 업데이트 포함 시 200+ 가능. - -### M3. `:switch-back` 시맨틱 공백 -CloudOnly 경로 이후 rollback은 "pre-switch ContextTarget"인가 "pre-switch CloudOnly request"인가? requirements에 미정의 — BL-P2-053/054 착수 시 재설계 유발. - -## LOW -**L1.** FR-6 "legacy :ctx 유지"는 올바르나 BL-P2-075 timeline을 명시하지 않아 tech debt hard deadline 부재. - -## 동의 -Assumption 2 (trait 확장 범위), FR-7 테스트 분기 커버리지는 타당. - -## 권고 -H1(fallback 정책), H2(picker 비용 근거 명문화), H3(전용 variant)를 해결하기 전 application-design 진입 금지. diff --git a/devflow-docs/inception/requirements-review-raw/quality.md b/devflow-docs/inception/requirements-review-raw/quality.md deleted file mode 100644 index ee94d5a..0000000 --- a/devflow-docs/inception/requirements-review-raw/quality.md +++ /dev/null @@ -1,34 +0,0 @@ -# Quality Review — BL-P2-074 requirements.md - -**Reviewer**: aidlc:quality-reviewer -**Date**: 2026-04-18T22:55+09:00 - -## Assessment -Requires Changes - -## Strengths -- Option (a)/(b) 선택 근거가 명시적이고, 미채택 이유가 타당 (BL 경계 존중). -- FR-3 resolver 알고리즘이 단계별로 기술되어 구현 모호성이 낮음. -- Assumption 3이 SwitchError variant 신설을 BL-P2-053과 의식적으로 분리 — 중복 방지 사고 good. -- 기존 `ByName` disambiguation 경로 재사용 결정(FR-3 step 4)이 DRY와 테스트 상속 측면에서 탁월. - -## Issues - -### P0 (Critical) -1. **FR-7 테스트 스코프 누락 — `CloudDirectory` trait 확장 파장 미반영.** 코드 조사 결과 `CloudDirectory` impl이 최소 4곳(`ConfigCloudDirectory`, `resolver.rs::tests::FakeClouds`, `app.rs:3444 FakeClouds`, `switcher.rs:213 FakeClouds`)에 존재. Assumption 2는 "기존 호출부 모두 업데이트 필요"라고만 적혀 있고, FR-7은 이를 테스트 책임으로 구체화하지 않음. NFR-2 "회귀 0건"을 강제하려면 **각 fake에 `default_project` 구현을 요구하는 명시 조항**이 필요. -2. **no-op/idempotency 테스트 누락 (FR-4).** FR-4는 "기존 idempotent 체크 재활용"에 의존하지만, 해당 체크의 실재 여부·위치(`SwitchContextSwitcher` 어느 지점인지)가 명시되지 않음. Acceptance criterion으로 **"동일 cloud 재입력 시 transition 카운터 불변" 단위 테스트**를 추가해야 함. - -### P1 (Important) -3. **`switch-cloud` toast 문구 의존 테스트 파급 누락.** `app.rs:2409 test_command_bar_switch_cloud_emits_info_toast`가 현재 `.contains("switch-cloud")`로 매칭 — 신규 동작은 성공 시 toast를 내지 않을 수 있고, help-toast의 "switch-cloud" 문자열과 충돌 가능. -4. **관측성/tracing 요구 부재.** `CloudOnly` dispatch는 기존 span 상속만으로 충분한지 혹은 "cloud=X, resolved_project=Y" 필드 추가가 필요한지 NFR에 답이 없음. -5. **Assumption 2 — `#[non_exhaustive]` 리스크.** `worker.rs:790` 및 `switcher.rs:321`이 `ContextRequest`를 match하므로 **리스트업된 업데이트 포인트**(resolver, switcher, worker, app dispatch)를 명시해야 clippy `-D warnings`에 의존하지 않는 사전 점검 가능. - -### P2 (Minor) -6. **FR-3 step 3 에러 메시지 분기 결정 불명확.** -7. **측정 가능성 — NFR-3 "+100~150 lines"**: "PR diff 상한" 정도로 표현 완화 권장. -8. **FR-5 에러 메시지 사용자 가이드 문구에 `:switch-project ` 중복** — Help toast(1758행)와 문구 일관성 확인 조항 누락. - -## Cross-cutting Notes -- **To security-reviewer**: `default_project_name`이 clouds.yaml에 담긴 사용자 제어 문자열이므로 toast 에러 메시지에 그대로 삽입될 경우 터미널 이스케이프 injection 가능성. -- **To maintainability-reviewer**: `CloudDirectory` trait가 세 번째 메서드를 얻는 순간, 향후 확장 압력이 커짐. -- **To spec-reviewer**: FR-4 no-op 조항은 측정 기준이 없어 구현 리뷰 시 "기존에 있었다"로 회피될 위험. diff --git a/devflow-docs/inception/requirements-review-raw/spec.md b/devflow-docs/inception/requirements-review-raw/spec.md deleted file mode 100644 index def9530..0000000 --- a/devflow-docs/inception/requirements-review-raw/spec.md +++ /dev/null @@ -1,30 +0,0 @@ -# Spec Review — BL-P2-074 requirements.md - -**Reviewer**: aidlc:spec-reviewer -**Date**: 2026-04-18T22:50+09:00 - -## Status -Issues Found (Important/Suggestion 위주, Critical 1건) - -## Critical -- **FR-5 edge case 불완전성**: "cloud with no auth config" (auth 블록 자체가 없는 clouds.yaml 엔트리) 케이스가 명시되지 않음. Assumption 1은 `CloudConfig.auth.project_name`을 단일 소스로 가정하지만 `auth` 자체가 None일 가능성은 다루지 않음. FR-3의 step 2/3은 `default_project_name: Option`만 다루므로 `auth` 부재 시 동일 경로로 수렴하는지 결정 필요. Acceptance 레벨에서 "auth=None → Option::None으로 coalesce"를 명시하면 해결됨. - -## Important -- **FR-4 testability 부족**: "resolved ContextTarget이 현재 활성과 동일하면 no-op"의 **관찰 가능한 결과**가 불분명. Toast가 뜨는지, state_machine transition 카운터가 증가하지 않는지, 어느 쪽을 assert할지 명시해야 테스트 작성 가능. "기존 switcher에 idempotent 체크가 있으면 재활용"은 설계 미결(Open Question에 둬야 함). -- **FR-7 acceptance criteria 강도**: "통합: dispatch 검증"이 모호함. `Action::SwitchContext(ContextRequest::CloudOnly { cloud: "prod" })`가 정확히 1회 emit됨을 검증한다고 명시해야 INVEST의 Testable 충족. 실패 경로 3종(unknown / no default / stale)도 "toast 문자열 정확 매칭" vs "ToastLevel::Error 존재" 중 측정 기준을 고를 것. -- **Assumption 3 (SwitchError 재사용) 설계 제약 불충분**: "문자열 메시지로 default-project 부재 표현"은 BL-P2-053이 별도 variant를 도입할 때 마이그레이션 부담을 만듦. `// TODO(BL-P2-053)` 마커 의무화 같은 연결 조건이 FR 또는 NFR에 없으면 forward-compat 리스크. - -## Suggestion -- **NFR-3 (코드 규모)**는 스펙이 아니라 견적 — requirements보다는 application-design 산출물. 제거하거나 "참고용"으로 라벨링. -- **FR-2 "Non-exhaustive 영향 재확인"**: 재확인 결과를 이 문서 확정 전에 검증해 Assumption으로 승격하는 편이 INVEST의 Independent/Negotiable 측면에서 더 단단함. -- **누락 edge**: "0 projects available" (default_project_name은 있지만 keystone에 empty list) — FR-5의 "stale" 케이스에 흡수된다고 볼 수 있으나 명시하면 테스트 매트릭스가 깔끔. -- **FR-1 "파서 변경 없음"** 은 assumption이 아니라 검증된 사실 (`Command::SwitchCloud(String)` 이미 존재) — 근거 링크 한 줄 추가 권장. - -## Good -- **FR-6** Legacy `:ctx` 분리 유지로 scope creep 차단 — closed scope 원칙 명확. -- **옵션 (b) 거절 근거** 및 forward-compat 서술(picker가 CloudOnly 위에 얹힘)이 Negotiable 측면에서 모범적. -- **Assumption 1** `--cloud` CLI와 동일 소스 원칙 — 단일 소스 불변조건 보호. - -## Cross-cutting Notes -- **Security/Quality 리뷰어에게**: FR-5의 toast 메시지가 cloud name을 직접 보간(`cloud '' not found`). NFR-4가 "이미 파서 레벨 sanitization"이라 주장하나 safe_display 사용 조건이 "해당 필요 시"로 느슨함. -- **Quality 리뷰어에게**: FR-4 idempotency 경로는 설계 단계에서 switcher 내부 계약 확인 필수. diff --git a/devflow-docs/inception/requirements.md b/devflow-docs/inception/requirements.md index 9afc7e3..532e962 100644 --- a/devflow-docs/inception/requirements.md +++ b/devflow-docs/inception/requirements.md @@ -1,161 +1,143 @@ # Requirements Analysis -**BL**: BL-P2-074 **Depth**: Standard -**Timestamp**: 2026-04-19T09:20:00+09:00 (revised) -**Parent BL**: BL-P2-031 (PR#76 머지 완료, stub 남김) -**Reference**: `.archive/inception-20260418-223706/requirements.md` (BL-P2-031 원본 FR-1) -**Review**: 3자 리뷰 반영 완료 (`inception/requirements-review-raw/`) +**Timestamp**: 2026-04-24T10:50:00+09:00 +**Work Item**: BL-P2-085 (P0, Critical) — Cross-project scoping 전면 fix ## User Intent -PR#76(BL-P2-031 PR3)에서 의도적으로 toast-only stub으로 남긴 `:switch-cloud ` 명령을 완결한다. **옵션 (a) — `ContextRequest::CloudOnly { cloud }` variant 도입 + resolver default-project 위임**을 채택한다. 사용자가 `:switch-cloud prod`를 입력하면 해당 cloud의 **명시적으로 선언된 선호 project** (clouds.yaml의 신규 필드 `CloudConfig::default_project`)로 전환된다. +`:switch-project`로 project 전환 후 server create 시 `SecurityGroupNotFound` → ERROR 인스턴스가 발생하는 근본 원인인 **4겹 cross-project scoping 버그**를 구조적으로 제거한다. admin 토큰 사용자가 active scope와 불일치하는 project의 리소스를 mutation/delete 할 수 있는 운영 사고 위험을 차단한다. -**Default project source 결정** (H1 adversarial 리뷰 반영): -- `CloudConfig`에 **신규 필드 `default_project: Option` 추가** — 기존 `auth.project_name`(Keystone bootstrap credential scope)과 분리. -- `auth.project_name` 재사용 금지 — 많은 환경이 `admin`/`service`로 고정되어 있어 runtime switch default로 쓰면 사용자가 실수로 admin scope에 착지할 수 있음 (OWASP A01 privilege 오남용 표면). PR#76 ConfirmDialog fingerprint가 파괴적 액션을 안전하게 만들려는 노력과 정면 충돌. -- `default_project`가 설정되지 않은 cloud에 대한 `:switch-cloud` 호출은 `SwitchError::NotConfigured` 반환 + toast로 `:switch-project ` 사용 유도. +PR#82 hotfix(c4590ab, 2026-04-24 머지)가 표면 증상(server form stale cache + 동명 옵션 disambiguation)만 차단했으므로, 이 위에 구조적 fix를 누적한다. -**옵션 (b) picker 두 단계 플로우 거절 비용 근거**: -- Unit 6 ContextPicker는 별도 BL로 스코프 분리되어 있음 (BL-P2-075의 의존). 이 BL에서 picker를 먼저 짓는 것은 단일 PR에 "cloud-only variant + default 정책 + 신규 picker UI + 테스트" 4축을 압축하는 것 → 리뷰 부담 + 회귀 위험 증가. -- 현재 toast 힌트 "(picker: Ctrl+P — Unit 6)"는 **미래 기능 예고**이지 `:switch-cloud` 차단 계약이 아님. CloudOnly 경로는 picker와 orthogonal — picker 도입 후에도 "explicit choice 레이어 아래의 빠른 기본값"으로 자연스럽게 공존 (zsh의 `cd` vs fuzzy `cdi` 관계). -- **현재 BL은 stub 제거가 최우선 목표**. picker 통합은 Unit 6 착수 시 별도 BL로 병합. +### 확정된 정책 모델 (해석 분기 결과) -## Functional Requirements +**A (엄격 차단) + C-lite (구조화 이벤트 로그)** 채택. -### FR-1. `:switch-cloud ` 즉시 전환 (Must) -- 사용자가 `:switch-cloud ` 입력 시, `Command::SwitchCloud(name)` → `Action::SwitchContext(ContextRequest::CloudOnly { cloud: name })` dispatch. -- 현재 toast-only stub (src/app.rs:1771-1780)을 실제 dispatch로 교체. -- 파서 변경 없음 — `Command::SwitchCloud(String)` 이미 존재 (src/input/command.rs:205 확인, `test_parse_switch_cloud` 통과). - -### FR-2. `ContextRequest::CloudOnly { cloud }` variant (Must) -- `src/context/types.rs`의 `ContextRequest` enum에 `CloudOnly { cloud: String }` variant 추가. `ByName` / `ById`와 병렬. -- `ContextRequest`는 `#[non_exhaustive]`가 **없고** 구조적 매칭이 전제 → 컴파일러가 모든 매처 업데이트를 강제. 업데이트 필수 사이트 (grep 검증 완료): - 1. `src/context/resolver.rs` — `ContextTargetResolver::resolve` arm - 2. `src/context/switcher.rs` — `SwitchContextSwitcher` 내부 매처 - 3. `src/worker.rs` — context-switch dispatch 경로 - 4. `src/app.rs` — handler / tests -- clippy `-D warnings`에 의존하지 않고 사전 컴파일로 검증. - -### FR-3. Resolver default-project 위임 (Must) -- `ContextTargetResolver::resolve(CloudOnly { cloud })` 경로 구현. -- 해결 알고리즘: - 1. cloud가 known_clouds에 있는지 확인 (없으면 `SwitchError::NotFound`). - 2. `CloudDirectory::default_project(cloud)` 조회 (신규 trait 메서드, `CloudConfig::default_project` 기반). - 3. `None` → `SwitchError::NotConfigured { cloud }` (FR-8로 신설). - 4. 있으면 그 project 이름으로 `ByName` 경로 재사용 → disambiguation + `ContextTarget` 반환. -- `CloudDirectory` trait에 `default_project(cloud: &str) -> Option` 추가. - -### FR-4. 이미 해당 cloud의 default project에 있을 때 no-op (Should) -- `:switch-cloud ` 입력 시 resolver가 반환한 `ContextTarget`이 현재 활성 `ContextTarget`과 동일하면, state_machine transition 생략 + `ToastLevel::Info` "already on " 발행. -- **Acceptance 측정 기준** (quality P0 + spec Important 반영): - - 단위 테스트: `SwitchContextSwitcher` 또는 상위 dispatcher에서 동일 target 재입력 시 `Switching` 상태 전이 카운터가 0 증가함을 assert. - - 구현 위치 결정 (설계 단계): switcher 내부 idempotent 체크 (쉬움) vs app-level pre-check (명시적). **예비 결정: switcher 내부**, application-design에서 최종 확정. - -### FR-5. Error 경로 가시적 처리 (Must) -- unknown cloud → toast `cloud '' not found` (`SwitchError::NotFound` 매핑, safe_display 적용 — **60자 truncate + 제어문자 제거**). -- `CloudConfig::default_project` 없음 → toast `cloud '' has no default project — use :switch-project ` (`SwitchError::NotConfigured` 매핑, safe_display 적용). -- auth 블록 자체가 None인 clouds.yaml 엔트리 → `CloudConfig::default_project`도 None으로 coalesce → 위와 동일 NotConfigured 경로 (spec Critical 반영). -- `default_project` 설정됐으나 Keystone에 없음(stale) → 기존 resolver `NotFound` 경로. -- 0 projects available (keystone empty list) → `NotFound` 경로에 흡수. -- 모든 에러는 `ToastLevel::Error`, 상태 전이 없음, help toast(src/app.rs:1758)와 guidance 문구 일관성 유지. - -### FR-6. Legacy :ctx와 분리 유지 (Must) -- `Command::ContextSwitch` / `Command::ContextList`는 PR3와 동일하게 toast-only 유지. 이번 BL에서 변경하지 않음. -- BL-P2-075 (Unit 6 이후 deprecate)가 처리. - -### FR-7. 테스트 (Must) -**단위 테스트**: -- `ContextRequest::CloudOnly` variant 생성/매칭 테스트 (types.rs). -- Resolver 성공 경로: `CloudOnly { cloud: "devstack" }` → default_project 기반 `ContextTarget` 반환. -- Resolver 실패 경로 3종: - - unknown cloud → `SwitchError::NotFound` - - default_project 없음 → `SwitchError::NotConfigured` - - default_project 설정되어 있으나 Keystone에 없음 → `SwitchError::NotFound` -- FR-4 idempotency: 동일 target 재입력 시 transition 카운터 불변. - -**`CloudDirectory` trait impl 4개 사이트 전수 업데이트 및 테스트** (quality P0 반영): -1. `src/context/config_cloud_directory.rs::ConfigCloudDirectory` — `default_project(&str)` 실제 구현 (CloudConfig 참조). -2. `src/context/resolver.rs::tests::FakeClouds` — 테스트용 stub. -3. `src/app.rs::tests::FakeClouds` (~line 3444) — stub. -4. `src/context/switcher.rs::tests::FakeClouds` (~line 213) — stub. - -**통합 테스트** (src/app.rs::tests): -- `:switch-cloud prod` 입력 → `Action::SwitchContext(ContextRequest::CloudOnly { cloud: "prod" })`가 **정확히 1회** emit됨 (spec Important 반영). -- 기존 `test_command_bar_switch_cloud_emits_info_toast`는 **신규 동작으로 대체** — 성공 경로는 dispatch assert, 실패 경로는 `ToastLevel::Error` + 메시지 substring assert. -- help toast(src/app.rs:1758)의 "switch-cloud" 문자열과의 regex 충돌 점검 (quality P1 반영). - -### FR-8. `SwitchError::NotConfigured { cloud: String }` variant (Must) -- `src/context/error.rs`에 variant 추가. -- 용도: cloud가 `default_project` 설정 없이 `:switch-cloud`로 접근되었을 때. -- **전용 variant 도입 이유** (H3 반영): 문자열 substring으로 fallback하면 BL-P2-053(SwitchError 확장 BL) 착수 시 테스트 + 사용자 facing message 양쪽이 깨짐. 지금 variant 하나 추가가 migration 비용 최소. -- `SwitchError`는 `#[non_exhaustive]`가 없어 매처 전수 업데이트 필요 (FR-2와 동일 사이트 집합 + error 경로 핸들러). +- 모든 사용자(admin 포함)는 `active_scope`와 불일치하는 project 리소스를 mutation/delete 할 수 **없다**. UI가 해당 경로를 막는다. +- 차단되는 모든 cross-project 시도를 구조화된 `cross_project_block` 이벤트로 로그에 기록 → 감사 + 사용자 패턴 분석 양쪽에 재사용 가능한 스키마. +- **기각된 대안**: + - B (opt-in) — B2(broad)는 현재 단일-프로젝트 UI 컨셉을 크게 재설계해야 하고 BL 한 건 범위를 초과(Unit 6 ContextPicker 연관). B1/B3은 운영 편의성 대비 구현 비용 정당화 어려움. 필요 시 별도 BL로 분리. + - 감사 뷰어 / 패턴 대시보드 = 이벤트 스키마는 이번에 픽스하되 뷰어/UI 소비는 후속 BL. -## Non-Functional Requirements +## Functional Requirements -### NFR-1. 동시성 격리 (기존 invariant 유지) -- 변경 없음. `CloudOnly` dispatch도 기존 epoch/state_machine 경로를 탄다. 추가 spawn이나 race 없음. +### FR1 [Critical] Adapter 읽기 경로 tenant-scoping +- `src/adapter/http/neutron.rs`의 SG / Network / FloatingIp List 빌더가 `active_scope`의 project_id를 `tenant_id` 쿼리 파라미터로 주입한다. +- `src/adapter/http/nova.rs`, `src/adapter/http/cinder.rs`의 list 엔드포인트는 `all_tenants` 플래그 모델로 통일 — 기본 미사용(scope-only), 의도적으로 필요한 상위 호출부만 명시 활성화. +- **수용 기준** (둘 다 충족): + - (요청 측) List 호출 시 HTTP 요청 쿼리에 `tenant_id={active_scope}` (또는 등가의 `all_tenants=false` + project-scoped token)이 실제로 포함됨을 mock HTTP가 assertion. + - (응답 측) mock이 cross-project 리소스를 섞어 반환해도 상위 레이어에 전달되는 결과는 `active_scope`에 속한 리소스만 포함. + +### FR2 [Critical] Worker mutation origin-scope guard (구 "target-scope guard") +- `src/worker.rs`의 모든 mutation Action dispatch 직전 **origin-scope 가드**를 수행한다. +- **개정 시맨틱** (2026-04-24, application-design Assumption A1 검증 후): `Action` enum이 `project_id`를 per-variant로 가지지 않음이 실측 확인되어, "target.project_id 비교" 대신 **"Action 생성 시점의 scope가 현재 active scope와 동일한지 비교"** 로 재정의. +- 구현: 중앙 dispatch 지점에서 `StampedAction { action, origin_project_id }` 래퍼로 감싸 Action을 발행. worker가 `stamped.origin_project_id == current_active_scope.project.id` 검사. +- **커버되는 위협 시나리오**: + - TOCTOU: 폼 open 당시 scope=A, 사용자가 `:switch-project B`로 전환한 뒤 폼을 submit → origin_project_id=A != active=B → 거부 + - Cache stale: mutation이 비동기 지연되는 동안 scope가 전환된 경우 동일하게 거부 +- 가드 실패 시: mutation을 거부하고 `cross_project_block` 이벤트(reason=`project_scope`)를 emit + 사용자에게 토스트로 즉시 알림. +- **Mutation 판정 기준**: `Action`에 `is_mutation()` 헬퍼 추가 (enum exhaustive match). read-only Action(`Fetch*`, `Navigate`, `Back` 등)은 가드 대상이 아님. 모든 Action은 `true`/`false` 중 하나로 명시적 분류되어야 하며, 미분류는 컴파일 에러. +- **ID 위조 시나리오 (target이 실제로 다른 scope)**: 이 가드가 직접 커버하지는 않음. FR1(adapter scope 필터)과 FR4(form 검증)가 커버. TUI 환경에서는 사용자가 직접 ID를 입력하는 경로가 제한적이므로 현실적 위협 최소. +- **수용 기준**: + - (a) mock worker 테스트에서 `origin_project_id=A`, `current_active=B`인 mutation Action dispatch가 `AppError::CrossProjectBlocked`(또는 application-design에서 확정된 variant)로 거부되고 이벤트가 기록됨. + - (b) `is_mutation()` 구현이 enum exhaustive → 미분류 Action은 컴파일 실패. + - (c) read-only Action은 `StampedAction` 래핑 없이 통과(또는 래핑되어도 guard 비대상 분기). + +### FR3 [Critical] RBAC project-mismatch 정책 +- `src/infra/rbac.rs`에 project-mismatch 차단 규칙을 추가한다. 현재 role-tier 가드 위에 누적(AND) 적용. +- RBAC 결정 순서: (1) role-tier 검사 → (2) project-scope 일치 검사. **둘 다 PASS**해야 Action 허용. role-tier PASS + scope FAIL 조합은 deny. +- 차단 결정 시 FR5 이벤트의 `reason` 필드가 `role_tier` / `project_scope` / `both`로 구분 가능해야 한다 (거부 원인 식별 가능). +- **수용 기준**: 단위 테스트로 role×scope 2차원 매트릭스(admin/member/reader × match/mismatch) 커버. admin+mismatch 케이스가 deny이고 reason이 `project_scope`로 기록됨. + +### FR4 [High] Form-selected ID cross-project 검증 +- `src/adapter/http/cinder.rs`의 CreateSnapshot 및 다른 form 기반 mutation에서 선택된 리소스 ID의 project_id가 현재 `active_scope`와 일치하는지 제출 시점에 검증한다. +- 검증 실패 시 form이 제출되지 않고 에러 토스트 표시. +- **수용 기준**: 단위 테스트에서 cross-project ID를 담은 폼 제출 요청이 거부됨. + +### FR5 [High] 구조화된 `cross_project_block` 이벤트 로깅 +- FR2/FR3/FR4의 차단 이벤트를 공통 스키마로 기록한다: + ``` + { + timestamp, actor_user_id, actor_cloud, + active_project_id, target_project_id, + action_type, resource_kind, resource_id, + outcome: "blocked", + reason, fingerprint + } + ``` +- 필드 정의: + - `reason`: `role_tier` / `project_scope` / `both` / `form_selection` / `adapter_filter` 중 하나. 차단을 유발한 계층 식별. + - `fingerprint`: `sha256(actor_user_id ∥ active_project_id ∥ target_project_id ∥ action_type ∥ resource_id)` 앞 12자. 동일 시도의 반복 그룹핑용. +- 출력 채널: 기존 nexttui 로그 파일 (`~/Library/Caches/nexttui/nexttui.log.`)에 append. Grep 가능한 prefix/tag 사용 (예: `[cross-project-guard]`). +- 전용 로그 파일 분리 여부는 구현 시 판단 (tag prefix로 충분하면 분리 불요). +- PII: `actor_user_id`는 Keystone user ID 평문 기록. 해싱은 후속 옵션 (이번 BL 범위 밖). +- **쓰기 보장 수준**: best-effort — IO 실패 시 기록은 유실될 수 있으나(단, 차단 자체는 이미 완료된 상태), 동일 프로세스 내 에러 로그(`tracing::error!`)에 남는다. "이벤트 손실 0%"는 보장하지 않는다 (NFR1과 일관). +- **수용 기준**: 차단 이벤트 발생 시 위 스키마의 structured log line이 파일에 기록된다. 필드 중 하나라도 누락되면 테스트 실패. + +### FR6 [Medium] 사용자 토스트 카피 +- 차단 시 사용자에게 표시할 토스트 메시지 공통 카피를 확정한다 (예: `"차단: 활성 프로젝트 '{active}'에서 '{target}' 리소스는 수정할 수 없습니다. :switch-project {target} 후 재시도하세요."`). +- 정확한 카피는 구현 시 확정 (i18n은 이번 범위 밖). +- **수용 기준**: (a) 차단 발생 시 UI 테스트/통합 테스트에서 토스트가 한 번 표시됨을 검증. (b) 토스트 메시지에 `active_project_name`과 `target_project_name` 두 값이 포함됨. (c) 공통 카피가 단일 모듈에서 생성되어 재사용(복붙 카피 금지). -### NFR-2. 테스트 회귀 0건 -- 기존 1314 tests 전부 통과 유지. PR3에서 커버한 `test_command_bar_switch_cloud_emits_info_toast`는 dispatch 검증으로 대체됨. -- `CloudDirectory` trait 확장으로 4개 impl 사이트 재컴파일 → 미업데이트 시 즉시 컴파일 실패로 탐지. +## Non-Functional Requirements -### NFR-3. 코드 규모 (Guidance only, not acceptance) -- 예상 diff: +150~250 lines (variant + NotConfigured variant + resolver arm + `default_project` accessor + 4 impl 사이트 + handler + 테스트). 단일 PR. **acceptance 기준이 아닌 가이드라인**. +### NFR1 보안 +- **차단 실패 금지 (우선순위 1)**: 4겹 버그 전부에 대해 false negative(차단해야 하는 시도가 통과)는 허용되지 않는다. 테스트로 보장. +- **이벤트 손실은 best-effort**: `cross_project_block` 이벤트는 IO 실패 시 로그 파일 append가 실패할 수 있으나, 그 경우에도 **차단 자체는 이미 완료**되어 있어야 한다. 이벤트 기록과 차단 행위는 독립 경로이며, 차단이 이벤트 기록에 의존하지 않는다. (FR5 "쓰기 보장 수준"과 일관) -### NFR-4. 보안 / OWASP -- 신규 외부 입력: `CloudConfig::default_project` (clouds.yaml 사용자 제어 문자열). 신뢰할 수 있으나 toast 표시 시 **`safe_display(&str, max_len=60)` 유틸 적용 필수** — 제어문자 / CR-LF injection / 터미널 이스케이프 방지. -- cloud name도 마찬가지 (파서 레벨 sanitization이 있어도 방어심층). -- 위험 scope 착지 방지: `default_project`를 명시적 필드로 분리한 것이 핵심 mitigation (H1 반영). +### NFR2 회귀 테스트 / CI 게이팅 +- **확정된 테스트 전략 = T1 (mock unit만 merge-blocking)**. +- mock HTTP 어댑터 + mock worker로 **FR1~FR5 전체 5개 FR** 단위 테스트 커버. ("4축"이 아니라 5개 FR — adapter(FR1) + worker(FR2) + rbac(FR3) + form(FR4) + log(FR5).) +- DevStack 통합 테스트(두 project 동명 SG 시나리오)는 **로컬/수동 실행만** — CI merge 게이트 아님. +- 이유: + - BL-P2-081이 `devstack-integration` CI placeholder의 "정식 activation" 우선권 보유 (메모리 기준). + - mock 단위 테스트가 deterministic + 빠름 + 구조 커버 충분. + - DevStack은 "실측 안전망"이지 매 PR 게이트가 아님 (flakiness 관리). -### NFR-5. Clippy `-D warnings` 유지 -- `ContextRequest` / `SwitchError` 모두 `#[non_exhaustive]` 부재로 컴파일러가 매처 업데이트를 강제. +### NFR3 호환성 +- PR#82 hotfix 위에 누적 — `build_disambiguated_opts` 헬퍼와 `on_context_changed` cache-clear 경로는 유지·재사용. +- `build_disambiguated_opts`를 다른 모듈(server 외 volume/snapshot/network 등)의 드롭다운에도 확장 여부는 **구현 재량** (이번 범위에 포함하면 Open Question 해결 후 결정). -### NFR-6. Observability / Tracing -- `CloudOnly` resolve/dispatch 경로에 기존 `tracing::info_span!` 상속 + 필드 추가: - - `cloud=` — 사용자 입력 그대로 - - `resolved_project=` — resolver가 선택한 default project -- 실패 시 `tracing::warn!` 이벤트 1건 (error variant 매핑). +### NFR4 브랜치/배포 정책 +- `main` 직접 커밋 금지. `feature/bl-p2-085-cross-project-scoping` (또는 도출된 유사 이름) 브랜치 + PR. +- CONSTRUCTION 완료 후 `/codex:review --scope branch --base main` 게이트 실행. 선택적으로 adversarial-review. ## Technology Stack -(brownfield — workspace.md 참조, 변경 없음) - | 계층 | 선택 | 소스 | 비고 | |------|------|------|------| -| Language | Rust (edition 2024) | Brownfield | 변경 없음 | -| TUI | ratatui 0.30 + crossterm 0.29 | Brownfield | 변경 없음 | -| Async | tokio + tokio-util | Brownfield | 변경 없음 | -| Test | built-in `#[cfg(test)]` | Brownfield | 변경 없음 | +| Language | Rust edition 2024 | Brownfield 감지 | 변경 없음 | +| Framework | ratatui 0.30 + crossterm 0.29 | Brownfield 감지 | 변경 없음 | +| HTTP | reqwest | Brownfield 감지 | 변경 없음 | +| Test | `#[cfg(test)]` + `tests/` integration | Brownfield 감지 | mock HTTP 어댑터 기반 unit 추가 | +| Logging | tracing | Brownfield 감지 | 구조화 이벤트 필드 추가 | + +→ 전체 스킵 (사전 지정 + Brownfield 완전 커버). ## Assumptions -1. **Default project source**: 신규 `CloudConfig::default_project: Option` 필드가 유일한 default source. `auth.project_name` fallback은 **하지 않음** — 의미 충돌 방지 (H1). -2. **`CloudDirectory` trait 확장**: 3개 메서드로 확장 (`active_cloud` / `known_clouds` / `default_project`). 4개 impl 사이트 전수 업데이트 (config_cloud_directory + 3 FakeClouds). -3. **`ContextRequest` 매처 사이트 전수 조사 완료**: resolver / switcher / worker / app — 컴파일러가 누락을 막음. -4. **State machine transition**: CloudOnly도 일반 ByName과 동일한 transition 규칙(Idle→Switching→Committed/Failed). 추가 상태 없음. -5. **이미 활성 project와 동일할 때**: resolver가 Ok(ContextTarget)를 반환하면 switcher 또는 app-level pre-check가 no-op 판단. FR-4 acceptance로 측정 가능. 구현 위치는 application-design에서 확정. -6. **Unit 6 ContextPicker 미구현 상태 유지**: 이 BL은 picker를 건드리지 않음. toast 힌트 "(picker: Ctrl+P — Unit 6)"도 그대로 — 미래 기능 예고로 해석. -7. **`:switch-back` 시맨틱 (post-CloudOnly)**: `CloudOnly { cloud }` → resolver가 `ContextTarget`으로 변환한 뒤부터는 기존 ByName 경로와 동일. 즉 `:switch-back`은 **pre-switch `ContextTarget`**으로 복귀 (pre-switch request가 아님). `ContextHistoryStore`는 이미 `ContextSnapshot`만 저장하므로 추가 작업 없음 (M3 반영). -8. **clouds.yaml 스키마 확장**: `default_project` 필드는 `Option` → 기존 clouds.yaml 파일과 100% backward compatible (serde(default)). 미설정 시 `None`. +Assumption 위험도 등급: ⚠️ = 거짓이면 설계/범위 재조정 필요. + +1. **⚠️** `active_scope`는 `src/context/` 모듈에서 전파되는 단일 값으로 이미 확립되어 있다 — 새로 도입할 필요 없음 (PR#80 `TokenScopeFingerprint` 등). **거짓 시 영향**: FR2/FR3/FR4의 "target vs active" 비교 기반이 흔들림 → application-design에서 전파 경로 실측 확인 선행 필요. +2. **⚠️** mock HTTP 테스트 인프라는 기존 `src/adapter/http/` 하위에 이미 존재하거나 일반 관례로 구축 가능하다. **거짓 시 영향**: NFR2 T1 전략 자체가 재설계 대상 (Construction 초반 체크 필요). +3. **⚠️** `build_disambiguated_opts` 헬퍼(PR#82 도입)는 서버 모듈 외 다른 모듈에도 "이름만 바꿔서" 적용 가능한 순수 함수 형태다 — 호출부만 추가하면 확장 가능. **거짓 시 영향**: NFR3 재사용 가정과 Open Q2(다른 모듈 확장 여부) 결정이 뒤집힘 → 확장 자체를 포기하거나 별도 리팩터링 선행. +4. `cross_project_block` 이벤트의 소비자(뷰어/대시보드)는 이번 BL 범위 밖이며, 후속 BL에서 동일 스키마를 기반으로 구축한다. +5. DevStack 싱글노드 환경은 개발자 로컬에서 이 BL의 수동 검증에 사용된다 (기동 중, 이전 세션 정보). + +⚠️ 표시된 가정은 CONSTRUCTION 진입 직후 실측 검증이 필수다 (application-design 또는 TDD RED 단계 초기). ## Open Questions -없음. 설계 단계(application-design)에서 확정할 항목: -- FR-4 idempotent 체크의 실제 위치 (switcher 내부 vs app-level pre-check) -- `SwitchError::NotConfigured` 표시 메시지 최종 문구 -- `default_project` config 스키마 위치 (cloud 레벨 직하 vs 기존 subfield 내부) +1. **[구현 단계에서 결정]** `tenant_id` 필터를 주입하지 못하는 특수 endpoint (예: Keystone admin-only global list, quota/pricing API 등)가 존재하는가? 존재하면 해당 endpoint는 FR1 대상에서 제외하고 worker/RBAC 레벨에서 커버한다. (application-design에서 adapter 매트릭스 작성 시 확정) +2. **[구현 단계에서 결정]** `build_disambiguated_opts`를 서버 외 모듈(volume/snapshot/network 등)로 확장할지 — 이번 PR 범위 포함 여부. (workflow-planning 또는 application-design의 component 스코프에서 확정) +3. **[구현 단계에서 결정]** Worker 거부 에러 타입 — `WorkerError::CrossProjectBlocked` 신규 variant vs 기존 `SwitchError`/`RbacDenied` 재사용. (application-design에서 에러 분류 확정) +4. **[workflow-planning에서 결정]** 구현을 1개 PR로 통합 vs adapter/worker/rbac 분할 PR. 안정성 우선과 리뷰 부담의 트레이드오프. ## Change Log - -- 2026-04-18T22:45 INITIAL — BL-P2-074 단독 requirements, 옵션 (a) 채택. -- 2026-04-19T09:20 REVISE — 3자 리뷰(spec/quality/adversarial) 반영. 주요 변경: - - H1: `auth.project_name` → 신규 `CloudConfig::default_project` 필드로 전환 (의미 충돌 해소) - - H3: `SwitchError::NotConfigured` 전용 variant 신설 (FR-8) - - FR-4 measurable acceptance 추가 (transition 카운터 불변 테스트) - - FR-7 확장: 4개 impl 사이트 + 4개 매처 사이트 + emit 1회 + failure 3종 - - NFR-4: toast safe_display 의무화 - - NFR-6: tracing 필드 커버 추가 - - Assumption 7: `:switch-back` post-CloudOnly 시맨틱 명시 - - Option (b) 거절 비용 근거 보강 - - NFR-3 "guidance only"로 완화 +- [2026-04-24T10:50:00+09:00] INITIAL — 4겹 버그 매핑 + A+C-lite 정책 모델 + T1 테스트 전략 기반 초안 작성 +- [2026-04-24T11:00:00+09:00] REVIEW-RESPONSE — spec-reviewer must-fix 3건 + should-consider 일부 반영: FR1 요청측 수용기준 추가, FR2 mutation 판정기준 명시, FR3 reason 분리 조건, FR5 fingerprint 정의 + best-effort 쓰기 보장, FR6 수용기준 추가, NFR1 손실-없음 문구 정정(best-effort), NFR2 "4축"→"5 FR" 정정, Assumptions 위험도 플래그 추가 +- [2026-04-24T11:50:00+09:00] DESIGN-DERIVED — application-design LIST에서 실측 확인으로 아래 보정: + - FR2 시맨틱 개정: `target.project_id == active_scope` → `origin_project_id == current_active_scope`. 근거: `Action` enum이 `project_id`를 per-variant로 가지지 않음 (55개 dispatch site). 중앙 dispatch 지점 `StampedAction` 래퍼로 스탬핑하여 TOCTOU/cache-stale 시나리오 커버. ID 위조 시나리오는 FR1+FR4에 위임. + - NFR2 용어 정정: mock HTTP 서버 도입 대신 **pure fn URL/query 빌더 추출** 방향 확정 (dev-dep 증가 없음, 기존 serde 단위 테스트 스타일과 일관). + - 용어 통일: `active_scope` → `TokenScope`/`ScopedAuthSession`/`RbacGuard::project_id()` 실제 타입으로 DETAIL 단계에서 매핑. diff --git a/devflow-docs/inception/workflow-plan.md b/devflow-docs/inception/workflow-plan.md index ad0b352..7fb75e7 100644 --- a/devflow-docs/inception/workflow-plan.md +++ b/devflow-docs/inception/workflow-plan.md @@ -1,75 +1,65 @@ # Workflow Plan -**Timestamp**: 2026-04-19T09:40:00+09:00 -**BL**: BL-P2-074 (SwitchCloud wire 완결) -**Parent BL**: BL-P2-031 (PR#76 머지 완료). 부모 workflow-plan은 `.archive/inception-20260418-223706/workflow-plan.md` 참조. -**Parent Requirements**: `devflow-docs/inception/requirements.md` FR-1 `:switch-cloud ` — cloud 전환 (프로젝트는 cloud 기본값 또는 미선택 상태) -**Selected Approach**: A안 (설계 먼저 + 단일 unit TDD) — 2026-04-19 확정 +**Timestamp**: 2026-04-24T11:15:00+09:00 +**Work Item**: BL-P2-085 — Cross-project scoping 전면 fix (P0, Critical) +**Selected Approach**: A안 Atomic Security Fix -## Context +## Approaches Considered -BL-P2-074는 BL-P2-031 PR#76의 toast-only stub(`src/app.rs:1771-1780`)을 완결한다. requirements.md FR-1의 "cloud 기본값" 경로를 구체화. +- **A안) Atomic Security Fix** — application-design Standard + 단일 PR + 5 FR atomic. 권장. P0 보안 경계의 rollback/일관성 단순화. +- **B안) Split Layered Rollout** — application-design Minimal + units 3개 + 3개 PR 순차. 리뷰 부담 분산하지만 **중간 상태에서 보안 경계 부분 적용 창**이 생겨 P0 긴급도에 부적합. -**Review artifacts**: `inception/requirements-review-raw/{spec,quality,adversarial}.md` — 3자 리뷰 원문 + 종합 의견. +## Approved Stages (A안 기준 초기값 — 게이트 확정 후 업데이트) -### BL-P2-074 확정 설계 결정 +### PRE-PLANNING +- user-stories: skipped — Bug-fix BL, INVEST 스토리 가치 낮음 (pre-planning gate=C) +- nfr-requirements: skipped — NFR은 requirements.md 내부로 충분 (pre-planning gate=C) -| 결정 | 선택 | 근거 | -|---|---|---| -| 구현 전략 | 옵션 (a) `ContextRequest::CloudOnly { cloud }` variant | 옵션 (b) picker 경로는 Unit 6 의존 → BL-P2-075로 분리 | -| Default project source | **신규 `CloudConfig::default_project: Option` 필드** | `auth.project_name` (Keystone bootstrap credential) 재사용은 admin scope 실착지 위험 (adversarial H1) | -| 미설정 cloud 전환 요청 | **`SwitchError::NotConfigured { cloud }` 전용 variant 신설** | 문자열 fallback은 BL-P2-053 migration 부담 (adversarial H3 + spec + quality 합의) | -| 동일 cloud 재입력 no-op | state_machine transition 카운터 불변 단위 테스트로 acceptance 측정 | FR-4 testability (3자 합의) | -| CloudDirectory trait | `default_project(&str) -> Option` 메서드 추가 | 4 impl 사이트 전수 업데이트 (ConfigCloudDirectory + 3 FakeClouds) | -| ContextRequest 매처 | resolver/switcher/worker/app 4개 사이트 전수 업데이트 | 컴파일러 강제 (`#[non_exhaustive]` 부재) | -| :switch-back post-CloudOnly | pre-switch `ContextTarget`으로 복귀 | `ContextHistoryStore` 기존 동작 유지 | -| Toast 안전 | `safe_display(60자 truncate + 제어문자 제거)` 의무화 | 터미널 이스케이프/CR-LF injection 방지 | +### CONSTRUCTION +- application-design: included — 5 FR 계층 매트릭스 + ⚠️ Assumption 3건 실측 검증 필요 +- units-generation: skipped — A안은 단일 atomic unit (보안 경계 fix 일체) +- code-generation: included — always (TDD 엄수) +- build-and-test: included — always -## Approaches Considered +## Stage Depths + +- application-design: **Standard** — LIST + DETAIL + review. NFR Design 비활성 (Comprehensive 아님). +- units-generation: N/A (skipped) +- code-generation: **Standard** — TDD protocol 적용 (RED-GREEN-REFACTOR, `_shared/tdd-protocol.md`) +- build-and-test: **Standard** — cargo test + clippy + audit + `/codex:review --scope branch --base main` 게이트 + +## Rationale (A안 선택 근거 요약) + +1. **P0 보안 경계 atomic rollback/일관성**: 5 FR 상호 의존 (reason 스키마, RBAC→worker, form→RBAC) → 분할 시 중간 창 + 스키마 drift 위험. +2. **상용 운영 조직 / 안정성 우선 철학**과 정합: 부분 적용 상태가 운영 사고 위험 창을 여는 것을 회피. +3. **리뷰 부담 관리 가능**: spec-reviewer (requirements에서 이미 1회) + R1 artifact-review (application-design) + `/codex:review` (CONSTRUCTION 완료 후) — 기존 체인으로 충분. Council은 저장된 `feedback_review_depth.md`에 따라 고위험 변경 시만 (현재 4겹 매핑 명확해서 불요). + +## Scope Exclusions (명시) -### A안) 설계 먼저 + 단일 unit TDD (권장) -- **포함**: application-design (Standard) → code-generation (Standard, TDD) → build-and-test (Standard) -- **스킵**: units-generation (단일 unit) -- **깊이**: Standard -- **적합**: `CloudConfig` 스키마 확장 + `CloudDirectory` trait 확장 + FR-4 idempotent 위치 + `SwitchError::NotConfigured` 통합을 단일 문서로 응축 → code-generation 중 흔들림 최소화 -- **주의**: +0.5 세션 오버헤드 (설계 문서 작성) +- `build_disambiguated_opts` 다른 모듈 확장 (Open Q2): **이번 범위 밖**. 필요 시 후속 BL로 분리. +- 감사 로그 뷰어 / 패턴 분석 대시보드: 이벤트 스키마만 이번에 픽스, UI 소비는 후속 BL. +- PII 해싱 옵션: 후속 BL. +- DevStack `devstack-integration` CI placeholder 활성화: BL-P2-081 우선권 보유. -### B안) 바로 구현 -- **포함**: code-generation (Minimal, TDD) → build-and-test (Standard) -- **스킵**: application-design, units-generation -- **깊이**: Minimal (code-generation) / Standard (build-and-test) -- **적합**: 설계 결정이 requirements-review-raw에서 이미 확정됐다고 간주, 바로 TDD 진입 -- **주의**: idempotent 위치, config 스키마 배치, serde 직렬화 호환성 등 세부 결정을 TDD 루프 안에서 내려야 함 → 리팩토링 증가 가능 +## Open Questions To Resolve Downstream -## Workflow Visualization (A안 기준) +- Q1 — `tenant_id` 필터 주입 불가 endpoint 여부 → application-design adapter 매트릭스 +- Q3 — Worker 거부 에러 variant → application-design 에러 분류 +- Q4 — 단일 PR vs 분할 PR → **A안 선택으로 확정: 단일 PR** + +## Workflow Visualization ``` INCEPTION - ✅ workspace-detection (완료, 델타) - ✅ requirements-analysis (완료, 3자 리뷰 반영) + ✅ workspace-detection (완료, gate=C) + ✅ complexity-declaration (완료, Standard) + ✅ requirements-analysis (완료, gate=B, 4 open Qs deferred) + ✅ pre-planning (완료, gate=C — user-stories/nfr 스킵) ⏭ workflow-planning (현재) - ➡ application-design [Standard] (A안) - ⏭ units-generation — 스킵 (A안: 단일 unit) + ➡ application-design [Standard] — LIST + DETAIL + review CONSTRUCTION - ➡ code-generation [Standard] (TDD RED-GREEN-REFACTOR) - ➡ build-and-test [Standard] + ⏭ units-generation — 스킵 (A안 기준, atomic 단일 변경 범위) + ➡ code-generation [Standard] — TDD RED-GREEN-REFACTOR + ➡ build-and-test [Standard] — cargo test + clippy + codex-review 게이트 ``` - -## Approved Stages (A안 기준 — gate 선택 대기) - -### PRE-PLANNING -- user-stories: skipped — Standard complexity + 단일 기능 완결 BL (Pre-Planning Gate C 선택) -- nfr-requirements: skipped — requirements 개정 + 3자 리뷰에서 NFR 응축 완료 (Pre-Planning Gate C 선택) - -### CONSTRUCTION -- application-design: included — `CloudConfig` 스키마 확장 + `CloudDirectory` trait 확장 + FR-4 idempotent 위치 + `SwitchError::NotConfigured` 통합 -- units-generation: skipped — 단일 unit (variant + handler + resolver + tests) -- code-generation: included — always (TDD protocol 적용) -- build-and-test: included — always - -## Stage Depths -- application-design: Standard -- units-generation: N/A (skipped) -- code-generation: Standard (TDD protocol — `_shared/tdd-protocol.md`) -- build-and-test: Standard diff --git a/devflow-docs/inception/workspace.md b/devflow-docs/inception/workspace.md index eb13c43..566b484 100644 --- a/devflow-docs/inception/workspace.md +++ b/devflow-docs/inception/workspace.md @@ -1,19 +1,20 @@ # Workspace Analysis **Detected**: Brownfield -**Timestamp**: 2026-04-18T22:40:00+09:00 -**Source**: 이전 분석(2026-04-16T12:30:00+09:00) 기반 + 델타 업데이트 +**Timestamp**: 2026-04-24T10:35:00+09:00 +**Source**: 이전 분석(2026-04-18T22:40:00+09:00) 기반 + 델타 업데이트 (PR#78/#80/#81/#82 반영) **Project Root**: /Users/jay.ahn/projects/infra/nexttui **Requires Path Confirmation**: false ## Project Structure -Rust TUI 애플리케이션. Component-Based + TEA 하이브리드 아키텍처, Port/Adapter 패턴, ModuleRegistry 기반. 17개 도메인 모듈(+HostModule), 128 .rs files, 1314 tests (PR#76 기준). PR#68에서 `src/context/` switch orchestration 추가, PR#75에서 main.rs runtime wire, PR#76에서 Commands & Safety UI (Unit 4.5 + Unit 5) 완료. +Rust TUI 애플리케이션. Component-Based + TEA 하이브리드 아키텍처, Port/Adapter 패턴, ModuleRegistry 기반. 17개 도메인 모듈(+HostModule), **132 .rs files**, **1370 tests** (PR#82 기준). 최근: PR#78 SwitchCloud wire, PR#80 same-cloud HTTP ProjectDirectory, PR#82 BL-P2-085 hotfix. ## Key Files Found - Cargo.toml, src/main.rs, src/lib.rs -- 128 .rs files across src/ -- .github/workflows/ci.yml (CI: fmt, test, clippy, audit) +- 132 .rs files across src/ +- .github/workflows/ci.yml (CI: fmt, test, clippy, audit, devstack-integration placeholder) - rust-toolchain.toml, .git-blame-ignore-revs +- tests/devstack_directory.rs (integration test) ## Pre-specified Tech Stack - **Source**: ~/CLAUDE.md (프로젝트 루트에는 CLAUDE.md 없음) @@ -25,13 +26,18 @@ Rust TUI 애플리케이션. Component-Based + TEA 하이브리드 아키텍처, - **Language**: Rust (edition 2024) - **Framework**: ratatui 0.30 + crossterm 0.29 - **Package Manager**: Cargo -- **Test Framework**: built-in (#[cfg(test)]) -- **Key Dependencies**: tokio, tokio-util (CancellationToken), reqwest, serde, tracing, chrono, async-trait, thiserror, http, **unicode-width 0.2** (신규 — PR#76 BL-P2-077) +- **Test Framework**: built-in (#[cfg(test)]) + integration (tests/) +- **Key Dependencies**: tokio, tokio-util (CancellationToken), reqwest, serde, tracing, chrono, async-trait, thiserror, http, unicode-width 0.2 ## Git Activity -- **Last Commit**: 2026-04-18 — 프로젝트 활성 -- **Recent Focus**: src/app.rs, src/context/*, src/ui/command_bar*, src/ui/context_indicator*, src/ui/confirm.rs, devflow-docs/backlog.md -- **Recent Commits**: chore(hooks) devflow-guard loosen (3735929), PR#76 머지 후 state 정리 (e7216ce), **PR#76 BL-P2-031 PR3 Commands & Safety UI** (d76e578), **PR#75 BL-P2-031 T3 runtime wire** (a00c044), BL-P2-064 backlog mark (82e9dc4) +- **Last Commit**: 2026-04-24 — 프로젝트 활성 +- **Recent Focus**: src/app.rs, src/module/server/mod.rs, src/module/user/mod.rs, src/module/project/mod.rs, src/module/host/mod.rs, devflow-docs/* +- **Recent Commits** (top 5): + - c4590ab PR#82 BL-P2-085 hotfix: server dropdown cache + cross-project disambiguation + - c0d20e2 PR#81 chore: devflow session-scoped artifacts gitignored + - 733d88f PR#80 BL-P2-080: same-cloud HTTP ProjectDirectory via /v3/auth/projects + - aca622c PR#79 chore(backlog) BL-P2-074 완료 마킹 + - af03fd9 PR#78 BL-P2-074 SwitchCloud wire ## Existing Documentation - README.md: 프로젝트 개요 @@ -39,10 +45,13 @@ Rust TUI 애플리케이션. Component-Based + TEA 하이브리드 아키텍처, - docs/git-blame-hygiene-in-ai-devflow.md: AI 협업 blame 위생 가이드 ## Code Structure -- **Directory Layout**: src/ (app, component, context, models, module, adapter, port, ui, infra, input) +- **Directory Layout**: src/ (app, component, context, models, module, adapter, port, ui, infra, input, router, event_loop, worker, background, action, demo, registry) - **Entry Points**: src/main.rs, src/lib.rs -- **Observed Patterns**: src 레이아웃, Port/Adapter (src/port/ + src/adapter/), Module 기반 도메인 분리 (src/module/), Context Switch Orchestration (src/context/) -- **New since 2026-04-16 analysis**: PR#75 — main.rs `wire_production_mode` 영역 확장 (switch-core 실제 연결). PR#76 — src/ui/command_bar.rs + command_bar_table.rs (Unit 4.5), src/ui/context_indicator.rs (Unit 5 Step 2), src/ui/confirm.rs 확장 (TypeToConfirm + fingerprint), src/ui/input_bar.rs 리팩터 (InputMode 단일화 BL-P2-073), src/app.rs Command parser/executor + SwitchCloud stub +- **Observed Patterns**: src 레이아웃, Port/Adapter (src/port/ + src/adapter/), Module 기반 도메인 분리 (src/module/), Context Switch Orchestration (src/context/), 워커 기반 mutation (src/worker.rs) +- **Adapter 레이아웃 (중요 — 이전 분석 기록 정정)**: + - `src/adapter/auth/` — Keystone auth/project directory/domain resolver/token cache/scoped session/rescope (10개 파일) + - `src/adapter/http/` — OpenStack API 어댑터: `keystone.rs`, `neutron.rs`, `nova.rs`, `cinder.rs`, `glance.rs`, `base.rs`, `endpoint_invalidator.rs`, `mod.rs` + - **⚠️ BL-P2-085 실제 대상은 `src/adapter/http/*.rs`** (인자에 있던 `src/adapter/openstack/*`는 부정확한 경로. inception에서 spec화 시 교정 필요) ## Coding Patterns (Sampled) - **Source**: src/component.rs @@ -51,9 +60,21 @@ Rust TUI 애플리케이션. Component-Based + TEA 하이브리드 아키텍처, - **Error Handling**: Result + thiserror, clippy deny unwrap/expect - **Comments**: 영어 doc comments, 한국어 인라인 주석 -## BL-P2-074 관련 현재 상태 -- `:switch-cloud ` 파서: `Command::SwitchCloud(String)` 생성 (src/input/command.rs) -- 실행부: src/app.rs:1771-1780 — toast-only stub (ContextRequest에 CloudOnly variant 부재) -- ContextRequest (src/context/types.rs:19): ByName / ById 두 variant, 둘 다 `project: String` 필수 -- ContextTargetResolver::list_projects(cloud) (src/context/resolver.rs:91) — 이미 존재, 옵션 (a)/(b) 공통 재사용 가능 -- Unit 6 ContextPicker: **미구현** (src/ui/에 없음, app.rs에서 "(picker: Ctrl+P — Unit 6)" toast로 announce만) +## BL-P2-085 관련 현재 상태 (2026-04-24 추가) + +### 건드릴 주요 파일 (크기 / 역할) +| 파일 | LoC | 현재 역할 | 예상 변경 축 | +|------|-----|----------|-----------| +| `src/adapter/http/neutron.rs` | 849 | SG / Network / FloatingIP List 빌더 | `tenant_id` 필터 auth scope 주입 (Critical) | +| `src/adapter/http/nova.rs` | 1147 | 서버/플레이버/키페어 API | `all_tenants` 플래그 모델 통일 (Critical) | +| `src/adapter/http/cinder.rs` | 567 | 볼륨/스냅샷/백업 API | `all_tenants` 통일 + form-selected ID 검증 (High) | +| `src/worker.rs` | 1269 | Action dispatch / mutation 실행 | 모든 mutation 직전 `target.project_id == active_scope` 가드 (Critical) | +| `src/infra/rbac.rs` | — | 역할 기반 정책 | project-mismatch 차단 정책 추가 (Critical) | +| `src/module/server/mod.rs` (PR#82) | — | 서버 폼 드롭다운 | `build_disambiguated_opts` 헬퍼 — **재사용 검토** (다른 module 폼에도 확장 가능) | + +### 테스트 스캐폴드 +- `tests/devstack_directory.rs` — PR#80이 도입한 통합 테스트. BL-P2-085 회귀 테스트(두 project에 동명 SG 배치 시나리오)는 이 파일 또는 신규 `tests/devstack_cross_project.rs`에 추가 예상. + +### 관련 타입 +- `active_scope` 참조 지점: main.rs, app.rs, context/state_machine, context/switcher, context/history, context/resolver, adapter/auth/* +- `TokenScopeFingerprint` (adapter/auth/token_scope_fingerprint.rs) — Unit 1 결과물, project 범위 식별자 diff --git a/src/action.rs b/src/action.rs index 4669319..02df53d 100644 --- a/src/action.rs +++ b/src/action.rs @@ -223,6 +223,43 @@ pub enum Action { SwitchBack, } +/// Envelope wrapping an [`Action`] with the active scope at dispatch time. +/// +/// FR2 (BL-P2-085): produced by [`crate::context::ActionSender::send`] which +/// stamps the current `project_id` for mutation actions. The worker compares +/// the stamp to the live active scope and rejects mismatches (TOCTOU / +/// cache-stale defense). Read-only actions carry `None` and bypass the guard. +#[derive(Debug, Clone)] +pub struct DispatchedAction { + pub action: Action, + /// `Some(project_id)` for mutations stamped at dispatch. + /// `None` for read-only actions or for senders without a scoped provider. + pub origin_project_id: Option, +} + +impl DispatchedAction { + /// Create a stamped envelope for a mutation action. + pub fn stamped(action: Action, origin_project_id: String) -> Self { + Self { + action, + origin_project_id: Some(origin_project_id), + } + } + + /// Create an unstamped envelope (read-only action). + pub fn unstamped(action: Action) -> Self { + Self { + action, + origin_project_id: None, + } + } + + /// True if this action was stamped at dispatch time. + pub fn is_stamped(&self) -> bool { + self.origin_project_id.is_some() + } +} + #[cfg(test)] mod tests { use super::*; @@ -372,4 +409,23 @@ mod tests { ]; assert_eq!(actions.len(), 8); } + + #[test] + fn test_dispatched_action_stamped_carries_origin() { + let act = Action::DeleteServer { + id: "srv-1".into(), + name: "web".into(), + }; + let dispatched = DispatchedAction::stamped(act.clone(), "admin-uuid".into()); + assert_eq!(dispatched.origin_project_id.as_deref(), Some("admin-uuid")); + assert!(dispatched.is_stamped()); + assert!(matches!(dispatched.action, Action::DeleteServer { .. })); + } + + #[test] + fn test_dispatched_action_unstamped_has_no_origin() { + let dispatched = DispatchedAction::unstamped(Action::FetchServers); + assert!(dispatched.origin_project_id.is_none()); + assert!(!dispatched.is_stamped()); + } } diff --git a/src/adapter/http/cinder.rs b/src/adapter/http/cinder.rs index d75afec..b4cef8f 100644 --- a/src/adapter/http/cinder.rs +++ b/src/adapter/http/cinder.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use super::{Link, append_pagination_parts, encode_param, extract_next_marker, paginated_list}; use crate::adapter::http::base::BaseHttpClient; +use crate::adapter::http::neutron_audit::CinderAuditCtx; +use crate::adapter::http::scope_refilter::{RefilterScope, refilter_and_audit}; use crate::models::cinder::{Volume, VolumeSnapshot}; use crate::port::auth::AuthProvider; use crate::port::cinder::CinderPort; @@ -14,6 +16,7 @@ use crate::port::types::*; pub struct CinderHttpAdapter { base: Arc, + audit_ctx: Option>, } impl CinderHttpAdapter { @@ -25,11 +28,63 @@ impl CinderHttpAdapter { EndpointInterface::Public, region, )?), + audit_ctx: None, }) } pub fn from_base(base: Arc) -> Self { - Self { base } + Self { + base, + audit_ctx: None, + } + } + + /// BL-P2-085 Step 14b: attach a `CinderAuditCtx` so every `list_*` call + /// runs `refilter_and_audit` against the response and emits an + /// `AdapterFilterViolation` audit event per dropped row. Wired by + /// `registry::new_http` once `audit.cinder` is provided. + pub fn with_audit(mut self, ctx: Arc) -> Self { + self.audit_ctx = Some(ctx); + self + } + + /// Apply response-side scope refiltering to a `PaginatedResponse`. + /// No-op when `audit_ctx` is None (pre-Step-14b adapters), preserving + /// the original response shape. When attached, partitions via + /// [`refilter_and_audit`] which fans out one `AdapterFilterViolation` + /// event per dropped row before returning the kept items. + /// + /// [`refilter_and_audit`]: crate::adapter::http::scope_refilter::refilter_and_audit + fn refilter_response( + &self, + resp: PaginatedResponse, + all_tenants: bool, + action_type: &str, + resource_kind: &str, + ) -> PaginatedResponse + where + T: crate::adapter::http::scope_refilter::ScopedItem, + { + let active = self + .audit_ctx + .as_ref() + .and_then(|ctx| ctx.scope_provider.current_project_id()); + let scope = RefilterScope::from_parts(active.as_deref(), all_tenants); + // correlation_id=0: list_* are not bound to a worker dispatch. + // See `NeutronHttpAdapter::refilter_response` for the rationale. + let kept = refilter_and_audit( + resp.items, + &scope, + self.audit_ctx.as_deref(), + action_type, + resource_kind, + 0, + ); + PaginatedResponse { + items: kept, + next_marker: resp.next_marker, + has_more: resp.has_more, + } } } @@ -157,7 +212,7 @@ impl CinderPort for CinderHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_volume_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/volumes/detail", &query, @@ -166,7 +221,8 @@ impl CinderPort for CinderHttpAdapter { (resp.volumes, next) }, ) - .await + .await?; + Ok(self.refilter_response(resp, filter.all_tenants, "FetchVolumes", "volume")) } async fn get_volume(&self, volume_id: &str) -> ApiResult { @@ -294,7 +350,7 @@ impl CinderPort for CinderHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_snapshot_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/snapshots/detail", &query, @@ -306,7 +362,8 @@ impl CinderPort for CinderHttpAdapter { (resp.snapshots, next) }, ) - .await + .await?; + Ok(self.refilter_response(resp, filter.all_tenants, "FetchSnapshots", "snapshot")) } async fn get_snapshot(&self, snapshot_id: &str) -> ApiResult { diff --git a/src/adapter/http/mod.rs b/src/adapter/http/mod.rs index 8732cfd..7900875 100644 --- a/src/adapter/http/mod.rs +++ b/src/adapter/http/mod.rs @@ -4,7 +4,9 @@ pub mod endpoint_invalidator; pub mod glance; pub mod keystone; pub mod neutron; +pub mod neutron_audit; pub mod nova; +pub mod scope_refilter; use serde::Deserialize; use serde::de::DeserializeOwned; diff --git a/src/adapter/http/neutron.rs b/src/adapter/http/neutron.rs index 0f295af..08706b1 100644 --- a/src/adapter/http/neutron.rs +++ b/src/adapter/http/neutron.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use super::{Link, append_pagination_parts, encode_param, extract_next_marker, paginated_list}; use crate::adapter::http::base::BaseHttpClient; +use crate::adapter::http::neutron_audit::NeutronAuditCtx; +use crate::adapter::http::scope_refilter::{RefilterScope, refilter_and_audit}; use crate::models::neutron::{ FloatingIp, Network, NetworkAgent, Port, PortBinding, SecurityGroup, SecurityGroupRule, }; @@ -15,6 +17,7 @@ use crate::port::types::*; pub struct NeutronHttpAdapter { base: Arc, + audit_ctx: Option>, } impl NeutronHttpAdapter { @@ -26,11 +29,67 @@ impl NeutronHttpAdapter { EndpointInterface::Public, region, )?), + audit_ctx: None, }) } pub fn from_base(base: Arc) -> Self { - Self { base } + Self { + base, + audit_ctx: None, + } + } + + /// BL-P2-085 Step 13b: attach a `NeutronAuditCtx` so every `list_*` call + /// runs `refilter_by_scope` against the response and emits an + /// `AdapterFilterViolation` audit event per dropped row. Wired by + /// `registry::new_http` (Step 13b-3). + pub fn with_audit(mut self, ctx: Arc) -> Self { + self.audit_ctx = Some(ctx); + self + } + + /// Apply response-side scope refiltering to a `PaginatedResponse`. + /// No-op when `audit_ctx` is None (pre-Step-13b-3 adapters), preserving + /// the original response shape. When attached, partitions via + /// [`refilter_and_audit`] which fans out one `AdapterFilterViolation` + /// event per dropped row before returning the kept items. + /// + /// [`refilter_and_audit`]: crate::adapter::http::scope_refilter::refilter_and_audit + fn refilter_response( + &self, + resp: PaginatedResponse, + all_tenants: bool, + action_type: &str, + resource_kind: &str, + ) -> PaginatedResponse + where + T: crate::adapter::http::scope_refilter::ScopedItem, + { + let active = self + .audit_ctx + .as_ref() + .and_then(|ctx| ctx.scope_provider.current_project_id()); + let scope = RefilterScope::from_parts(active.as_deref(), all_tenants); + // correlation_id=0: list_* are not bound to a worker dispatch, + // and the canonical fingerprint already disambiguates per-row + // events via `resource_id`. Replace with the dispatch epoch + // when worker→adapter epoch propagation lands (post-Step-14 + // refactor cycle is the natural slot — see Phase 8 cumulative + // cargo-review verdict). + let kept = refilter_and_audit( + resp.items, + &scope, + self.audit_ctx.as_deref(), + action_type, + resource_kind, + 0, + ); + PaginatedResponse { + items: kept, + next_marker: resp.next_marker, + has_more: resp.has_more, + } } } @@ -223,30 +282,44 @@ impl RuleDirection { // --- Query builders --- -// Neutron API does not support `all_tenants` query parameter. -// Admin tokens automatically see all projects' resources. -// The filter structs carry the flag for UI column logic only. +// BL-P2-085 Step 12: Neutron list endpoints accept `tenant_id={scope}` for +// strict project scoping and `all_tenants=1` (admin-only) to opt out. The two +// are mutually exclusive: `all_tenants=true` wins and `tenant_id` is omitted. +// When neither is set, the query falls back to pagination only (fail-safe — +// the server keeps its default scoping under the current admin token). -fn build_network_query(_filter: &NetworkListFilter, pagination: &PaginationParams) -> String { +fn build_network_query(filter: &NetworkListFilter, pagination: &PaginationParams) -> String { let mut parts = Vec::new(); + if filter.all_tenants { + parts.push("all_tenants=1".to_string()); + } else if let Some(ref tid) = filter.tenant_id { + parts.push(format!("tenant_id={}", encode_param(tid))); + } append_pagination_parts(&mut parts, pagination); parts.join("&") } fn build_security_group_query( - _filter: &SecurityGroupListFilter, + filter: &SecurityGroupListFilter, pagination: &PaginationParams, ) -> String { let mut parts = Vec::new(); + if filter.all_tenants { + parts.push("all_tenants=1".to_string()); + } else if let Some(ref tid) = filter.tenant_id { + parts.push(format!("tenant_id={}", encode_param(tid))); + } append_pagination_parts(&mut parts, pagination); parts.join("&") } -fn build_floating_ip_query( - _filter: &FloatingIpListFilter, - pagination: &PaginationParams, -) -> String { +fn build_floating_ip_query(filter: &FloatingIpListFilter, pagination: &PaginationParams) -> String { let mut parts = Vec::new(); + if filter.all_tenants { + parts.push("all_tenants=1".to_string()); + } else if let Some(ref tid) = filter.tenant_id { + parts.push(format!("tenant_id={}", encode_param(tid))); + } append_pagination_parts(&mut parts, pagination); parts.join("&") } @@ -263,7 +336,7 @@ impl NeutronPort for NeutronHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_network_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/v2.0/networks", &query, @@ -272,7 +345,8 @@ impl NeutronPort for NeutronHttpAdapter { (resp.networks, next) }, ) - .await + .await?; + Ok(self.refilter_response(resp, filter.all_tenants, "FetchNetworks", "network")) } async fn get_network(&self, network_id: &str) -> ApiResult { @@ -350,7 +424,7 @@ impl NeutronPort for NeutronHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_security_group_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/v2.0/security-groups", &query, @@ -362,7 +436,13 @@ impl NeutronPort for NeutronHttpAdapter { (resp.security_groups, next) }, ) - .await + .await?; + Ok(self.refilter_response( + resp, + filter.all_tenants, + "FetchSecurityGroups", + "security_group", + )) } async fn get_security_group(&self, sg_id: &str) -> ApiResult { @@ -463,7 +543,7 @@ impl NeutronPort for NeutronHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_floating_ip_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/v2.0/floatingips", &query, @@ -475,7 +555,8 @@ impl NeutronPort for NeutronHttpAdapter { (resp.floatingips, next) }, ) - .await + .await?; + Ok(self.refilter_response(resp, filter.all_tenants, "FetchFloatingIps", "floating_ip")) } async fn create_floating_ip(&self, params: &FloatingIpCreateParams) -> ApiResult { @@ -815,32 +896,116 @@ mod tests { } // --- Query builders --- + // BL-P2-085 Step 12: tenant_id injection / all_tenants=1 branch --- + // Policy: + // - filter.all_tenants == true → query contains "all_tenants=1", no tenant_id + // - filter.all_tenants == false && filter.tenant_id = Some(scope) + // → query contains "tenant_id={scope}", no all_tenants + // - filter.all_tenants == false && filter.tenant_id = None + // → query has neither (no-op fail-safe; pagination only) - // Neutron does not send all_tenants query param — admin token sees all automatically #[test] - fn test_build_network_query_no_all_tenants_param() { - let filter = NetworkListFilter { all_tenants: true }; + fn test_build_network_query_injects_tenant_id_when_all_tenants_false() { + let filter = NetworkListFilter { + all_tenants: false, + tenant_id: Some("proj-A".into()), + }; let pagination = PaginationParams::default(); let query = build_network_query(&filter, &pagination); + assert!( + query.contains("tenant_id=proj-A"), + "expected tenant_id=proj-A in query, got: {query}" + ); assert!(!query.contains("all_tenants")); } #[test] - fn test_build_security_group_query_no_all_tenants_param() { - let filter = SecurityGroupListFilter { all_tenants: true }; + fn test_build_network_query_all_tenants_true_skips_tenant_id() { + let filter = NetworkListFilter { + all_tenants: true, + tenant_id: Some("proj-A".into()), + }; + let pagination = PaginationParams::default(); + let query = build_network_query(&filter, &pagination); + assert!( + query.contains("all_tenants=1"), + "expected all_tenants=1 in query, got: {query}" + ); + assert!(!query.contains("tenant_id")); + } + + #[test] + fn test_build_security_group_query_injects_tenant_id_when_all_tenants_false() { + let filter = SecurityGroupListFilter { + all_tenants: false, + tenant_id: Some("proj-B".into()), + }; let pagination = PaginationParams::default(); let query = build_security_group_query(&filter, &pagination); + assert!( + query.contains("tenant_id=proj-B"), + "expected tenant_id=proj-B in query, got: {query}" + ); assert!(!query.contains("all_tenants")); } #[test] - fn test_build_floating_ip_query_no_all_tenants_param() { - let filter = FloatingIpListFilter { all_tenants: true }; + fn test_build_security_group_query_all_tenants_true_skips_tenant_id() { + let filter = SecurityGroupListFilter { + all_tenants: true, + tenant_id: Some("proj-B".into()), + }; + let pagination = PaginationParams::default(); + let query = build_security_group_query(&filter, &pagination); + assert!( + query.contains("all_tenants=1"), + "expected all_tenants=1 in query, got: {query}" + ); + assert!(!query.contains("tenant_id")); + } + + #[test] + fn test_build_security_group_query_no_op_when_no_tenant_id_no_all_tenants() { + let filter = SecurityGroupListFilter { + all_tenants: false, + tenant_id: None, + }; + let pagination = PaginationParams::default(); + let query = build_security_group_query(&filter, &pagination); + assert!(!query.contains("tenant_id")); + assert!(!query.contains("all_tenants")); + } + + #[test] + fn test_build_floating_ip_query_injects_tenant_id_when_all_tenants_false() { + let filter = FloatingIpListFilter { + all_tenants: false, + tenant_id: Some("proj-C".into()), + }; let pagination = PaginationParams::default(); let query = build_floating_ip_query(&filter, &pagination); + assert!( + query.contains("tenant_id=proj-C"), + "expected tenant_id=proj-C in query, got: {query}" + ); assert!(!query.contains("all_tenants")); } + #[test] + fn test_build_floating_ip_query_all_tenants_true_skips_tenant_id() { + let filter = FloatingIpListFilter { + all_tenants: true, + tenant_id: Some("proj-C".into()), + }; + let pagination = PaginationParams::default(); + let query = build_floating_ip_query(&filter, &pagination); + assert!( + query.contains("all_tenants=1"), + "expected all_tenants=1 in query, got: {query}" + ); + assert!(!query.contains("tenant_id")); + } + #[test] fn test_networks_response_no_links() { let json = r#"{ diff --git a/src/adapter/http/neutron_audit.rs b/src/adapter/http/neutron_audit.rs new file mode 100644 index 0000000..ae01cdc --- /dev/null +++ b/src/adapter/http/neutron_audit.rs @@ -0,0 +1,381 @@ +//! BL-P2-085 Step 13b / Step-14-precedent-refactor-2 — adapter-side audit +//! context for cross-project filter violations. +//! +//! `AuditCtx` bundles the three pieces every adapter `list_*` impl needs +//! to emit a `CrossProjectBlockEvent` with reason `AdapterFilterViolation` +//! per row that survives the server-side `tenant_id={scope}` filter +//! (Step 12) but still lands in `dropped` from `refilter_by_scope` +//! (Step 13a): +//! +//! - `logger` — the production `AuditLogger` (shared `Arc` so adapter and +//! worker write to the same file with a single `BufWriter`). +//! - `scope_provider` — live read of the active project id at emit time +//! (avoids stale capture across cloud/project switches). +//! - `actor_ctx` — `Arc>` shared with the worker so +//! `ContextChanged` updates to `cloud` / `user_id` are reflected here +//! without re-spawning the adapter. +//! +//! `NeutronAuditCtx` / `NovaAuditCtx` / `CinderAuditCtx` are type aliases +//! preserved so callers can keep service-named imports while the struct +//! itself is service-agnostic. Step-14-precedent-refactor-3 will add the +//! `service: &'static str` discriminator and `AdapterAuditConfig` bundle. + +use std::sync::{Arc, RwLock}; + +use crate::adapter::http::scope_refilter::{AuditEmitter, ScopedItem}; +use crate::context::action_channel::ScopeProvider; +use crate::infra::audit::AuditLogger; +use crate::infra::cross_project_audit::{self, CrossProjectBlockEvent}; +use crate::infra::cross_project_guard::{CrossProjectReason, GuardLayer}; +use crate::worker::ActorContext; + +/// Three pieces every adapter `list_*` impl needs to emit a per-row +/// `AdapterFilterViolation` event when [`refilter_by_scope`] returns a +/// non-empty `dropped` set. Service-agnostic — Step 14 adapters +/// (Nova/Cinder) reuse the same struct via type aliases. +/// +/// [`refilter_by_scope`]: crate::adapter::http::scope_refilter::refilter_by_scope +pub struct AuditCtx { + /// Production audit logger (rotation + sensitive masking). The same + /// `Arc` is shared with `App` and the worker so all three writers + /// land in a single `BufWriter` rotation, avoiding interleaving. + pub logger: Arc, + /// Source of the current active project id, read live at each emit. + /// Backed by `RbacGuard` in production; `None` from the provider + /// means the user is unscoped (rare; refilter then short-circuits). + pub scope_provider: Arc, + /// Cloud / user_id snapshot, mutated by `App::handle_event` on + /// `ContextChanged` (BL-P2-074 cloud switch). Read under the lock + /// once per `emit_filter_violations` call so the entire dropped set + /// gets a consistent attribution. + pub actor_ctx: Arc>, + /// Service discriminator (`"neutron"` / `"nova"` / `"cinder"`), + /// stamped by [`build_audit_config`]. Currently exposed via + /// [`AuditCtx::service`] for analysis/grep workflows; future audit + /// details enrichment may include it on the wire (would bump the + /// fingerprint canonical from v1 — defer to that schema cycle). + pub service: &'static str, +} + +impl AuditCtx { + /// Read the service discriminator. Equivalent to `self.service` but + /// kept as a getter so the field can later become private without + /// breaking call sites. + pub fn service(&self) -> &'static str { + self.service + } +} + +/// Service-named alias retained for callers that prefer explicit +/// service tagging (registry/main wiring, NeutronHttpAdapter::with_audit). +pub type NeutronAuditCtx = AuditCtx; +/// Step 14 placeholder — Nova adapter wiring will use this alias. +pub type NovaAuditCtx = AuditCtx; +/// Step 14 placeholder — Cinder adapter wiring will use this alias. +pub type CinderAuditCtx = AuditCtx; + +/// Bundle of per-service [`AuditCtx`] instances passed to +/// [`crate::adapter::registry::AdapterRegistry::new_http`]. Replaces the +/// legacy 3-arg shape so Step 14 (Nova/Cinder) doesn't push the registry +/// signature past 5 arguments. Each field is `Option` because mock +/// registries and integration tests construct without an audit logger. +#[derive(Default)] +pub struct AdapterAuditConfig { + pub neutron: Option>, + pub nova: Option>, + pub cinder: Option>, +} + +/// Build an [`AdapterAuditConfig`] from the three pieces every service +/// audit ctx shares. Returns `Default` (all `None`) when `audit_logger` +/// is `None` so mock paths and audit-disabled environments don't pay the +/// `Arc` allocation. Each Some-arm shares the same `logger` / +/// `scope_provider` / `actor_ctx`; only the `service` discriminator +/// differs, matching the Step-14 plan to colocate per-service tagging +/// in one place rather than at every adapter call site. +pub fn build_audit_config( + audit_logger: Option>, + scope_provider: Arc, + actor_ctx: Arc>, +) -> AdapterAuditConfig { + let Some(logger) = audit_logger else { + return AdapterAuditConfig::default(); + }; + let make = |service: &'static str| -> Arc { + Arc::new(AuditCtx { + logger: logger.clone(), + scope_provider: scope_provider.clone(), + actor_ctx: actor_ctx.clone(), + service, + }) + }; + AdapterAuditConfig { + neutron: Some(make("neutron")), + nova: Some(make("nova")), + cinder: Some(make("cinder")), + } +} + +impl AuditEmitter for AuditCtx { + /// Emit one `CrossProjectBlockEvent` per dropped row. No-op when + /// `dropped.is_empty()` to avoid touching the audit log on the common + /// (zero-violation) path. Reads `actor_ctx` and `scope_provider` *at + /// emit time* so cloud/project switches between the list call and the + /// emit are reflected. + fn emit_filter_violations( + &self, + dropped: &[T], + action_type: &str, + resource_kind: &str, + correlation_id: u64, + ) { + if dropped.is_empty() { + return; + } + let active = self.scope_provider.current_project_id(); + let (cloud, user_id) = { + let ctx = self.actor_ctx.read().unwrap_or_else(|p| p.into_inner()); + (ctx.cloud.clone(), ctx.user_id.clone()) + }; + for item in dropped { + let resource_id = item.resource_id().unwrap_or("?").to_string(); + let project_id = item.tenant_id().unwrap_or("").to_string(); + // `CrossProjectBlockEvent::new` (Step 11b ctor) leaves the + // top-level `resource_id` as `None` because the worker path + // doesn't always know one. The adapter path always does, so + // we promote it into the fingerprint-relevant slot after + // construction. Same value as the one packed inside + // `AdapterFilterViolation::resource_id`; no semantic + // duplication, just two views of the same row. + let mut event = CrossProjectBlockEvent::new( + CrossProjectReason::AdapterFilterViolation { + resource_id: resource_id.clone(), + project_id, + }, + GuardLayer::Fr1Adapter, + action_type, + resource_kind, + cloud.clone(), + user_id.clone(), + active.clone(), + None, + correlation_id, + ); + event.resource_id = Some(resource_id); + cross_project_audit::emit(&event, Some(&self.logger)); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, RwLock}; + + use tempfile::TempDir; + + use super::*; + use crate::adapter::http::neutron::NeutronHttpAdapter; + use crate::adapter::http::scope_refilter::ScopedItem; + use crate::context::action_channel::ScopeProvider; + use crate::infra::audit::AuditLogger; + use crate::worker::ActorContext; + + /// Stub scope provider returning a fixed project id. + struct FixedScope(Option); + impl ScopeProvider for FixedScope { + fn current_project_id(&self) -> Option { + self.0.clone() + } + } + + /// Test fixture row mirroring the minimum ScopedItem surface. + struct Row { + id: &'static str, + tenant: Option<&'static str>, + } + impl ScopedItem for Row { + fn tenant_id(&self) -> Option<&str> { + self.tenant + } + fn resource_id(&self) -> Option<&str> { + Some(self.id) + } + } + + fn build_ctx(dir: &TempDir, active: Option<&str>) -> NeutronAuditCtx { + let logger = Arc::new(AuditLogger::new(dir.path().join("audit.log")).unwrap()); + let scope: Arc = Arc::new(FixedScope(active.map(str::to_string))); + let actor = Arc::new(RwLock::new(ActorContext { + cloud: "devstack".into(), + user_id: "user-uuid".into(), + })); + NeutronAuditCtx { + logger, + scope_provider: scope, + actor_ctx: actor, + service: "neutron", + } + } + + fn read_audit_lines(dir: &TempDir) -> Vec { + let path = dir.path().join("audit.log"); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).unwrap()) + .collect() + } + + #[test] + fn test_neutron_audit_ctx_emit_one_event_per_dropped() { + let dir = TempDir::new().unwrap(); + let ctx = build_ctx(&dir, Some("proj-A")); + let dropped = vec![ + Row { + id: "x1", + tenant: Some("proj-B"), + }, + Row { + id: "x2", + tenant: Some("proj-C"), + }, + Row { + id: "x3", + tenant: None, + }, + ]; + ctx.emit_filter_violations(&dropped, "FetchSecurityGroups", "security_group", 99); + + let lines = read_audit_lines(&dir); + assert_eq!(lines.len(), 3, "one log line per dropped row"); + let ids: Vec<&str> = lines + .iter() + .filter_map(|v| v["resource_id"].as_str()) + .collect(); + assert!(ids.contains(&"x1")); + assert!(ids.contains(&"x2")); + assert!(ids.contains(&"x3")); + } + + #[test] + fn test_neutron_audit_ctx_no_emit_when_dropped_empty() { + let dir = TempDir::new().unwrap(); + let ctx = build_ctx(&dir, Some("proj-A")); + let dropped: Vec = Vec::new(); + ctx.emit_filter_violations(&dropped, "FetchNetworks", "network", 1); + + let lines = read_audit_lines(&dir); + assert!( + lines.is_empty(), + "no audit lines must be emitted when dropped is empty" + ); + } + + #[test] + fn test_neutron_audit_ctx_uses_fr1_adapter_layer_in_event() { + let dir = TempDir::new().unwrap(); + let ctx = build_ctx(&dir, Some("proj-A")); + let dropped = vec![Row { + id: "fip-1", + tenant: Some("proj-other"), + }]; + ctx.emit_filter_violations(&dropped, "FetchFloatingIps", "floating_ip", 7); + + let lines = read_audit_lines(&dir); + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0]["details"]["guard_layer"], "fr1_adapter", + "AdapterFilterViolation must stamp Fr1Adapter layer" + ); + } + + #[test] + fn test_neutron_audit_ctx_uses_adapter_filter_violation_reason() { + let dir = TempDir::new().unwrap(); + let ctx = build_ctx(&dir, Some("proj-A")); + let dropped = vec![Row { + id: "sg-1", + tenant: Some("proj-other"), + }]; + ctx.emit_filter_violations(&dropped, "FetchSecurityGroups", "security_group", 0); + + let lines = read_audit_lines(&dir); + assert_eq!(lines.len(), 1); + let result = &lines[0]["result"]; + assert_eq!( + result["failed"], + serde_json::Value::String("cross_project_block:adapter_filter_violation".to_string()), + "result must encode the AdapterFilterViolation reason" + ); + } + + #[test] + fn test_neutron_with_audit_attaches_ctx_default_none() { + // Default constructor (Step 13a tree) leaves audit_ctx None; + // with_audit builder attaches an Arc. + let dir = TempDir::new().unwrap(); + let ctx = Arc::new(build_ctx(&dir, Some("proj-A"))); + + // Skip BaseHttpClient construction (auth provider not in scope); + // verify the NeutronHttpAdapter type exposes the builder shape. + // The actual audit_ctx field is private; we observe it via the + // builder's return type and lack of panics — Step 13b-2 GREEN + // makes the field accessor public-or-pkg-visible if needed. + let _builder_signature: fn(NeutronHttpAdapter, Arc) -> NeutronHttpAdapter = + NeutronHttpAdapter::with_audit; + // Reference ctx so the binding isn't dropped before the assertion. + let _ = ctx; + } + + // --- BL-P2-085 Step-14-precedent-refactor-cycle (refactor-3) --- + // `AdapterAuditConfig` bundles the three per-service audit ctxs into a + // single registry::new_http argument. `build_audit_config` is the + // canonical constructor — it tags each ctx with its service name so + // future audit details enrichment can discriminate per-service. + + fn dummy_actor_ctx() -> Arc> { + Arc::new(RwLock::new(ActorContext { + cloud: "devstack".into(), + user_id: "user-uuid".into(), + })) + } + + #[test] + fn test_adapter_audit_config_default_all_none() { + let cfg = AdapterAuditConfig::default(); + assert!(cfg.neutron.is_none()); + assert!(cfg.nova.is_none()); + assert!(cfg.cinder.is_none()); + } + + #[test] + fn test_build_audit_config_returns_none_when_logger_none() { + let scope: Arc = Arc::new(FixedScope(None)); + let cfg = build_audit_config(None, scope, dummy_actor_ctx()); + assert!(cfg.neutron.is_none()); + assert!(cfg.nova.is_none()); + assert!(cfg.cinder.is_none()); + } + + #[test] + fn test_build_audit_config_returns_three_service_tagged_ctxs_when_logger_some() { + let dir = TempDir::new().unwrap(); + let logger = Arc::new(AuditLogger::new(dir.path().join("audit.log")).unwrap()); + let scope: Arc = Arc::new(FixedScope(Some("proj-A".into()))); + let cfg = build_audit_config(Some(logger), scope, dummy_actor_ctx()); + + let neutron = cfg + .neutron + .expect("neutron ctx must be Some when logger is Some"); + assert_eq!(neutron.service(), "neutron"); + + let nova = cfg.nova.expect("nova ctx must be Some when logger is Some"); + assert_eq!(nova.service(), "nova"); + + let cinder = cfg + .cinder + .expect("cinder ctx must be Some when logger is Some"); + assert_eq!(cinder.service(), "cinder"); + } +} diff --git a/src/adapter/http/nova.rs b/src/adapter/http/nova.rs index 4cc8a65..4bbab03 100644 --- a/src/adapter/http/nova.rs +++ b/src/adapter/http/nova.rs @@ -10,6 +10,8 @@ use super::{ paginated_list, }; use crate::adapter::http::base::BaseHttpClient; +use crate::adapter::http::neutron_audit::NovaAuditCtx; +use crate::adapter::http::scope_refilter::{RefilterScope, refilter_and_audit}; use crate::models::nova::{Aggregate, ComputeService, Flavor, Hypervisor, Server, ServerMigration}; use crate::port::auth::AuthProvider; use crate::port::error::{ApiError, ApiResult}; @@ -18,6 +20,7 @@ use crate::port::types::*; pub struct NovaHttpAdapter { base: Arc, + audit_ctx: Option>, } impl NovaHttpAdapter { @@ -29,11 +32,63 @@ impl NovaHttpAdapter { EndpointInterface::Public, region, )?), + audit_ctx: None, }) } pub fn from_base(base: Arc) -> Self { - Self { base } + Self { + base, + audit_ctx: None, + } + } + + /// BL-P2-085 Step 14: attach a `NovaAuditCtx` so every `list_*` call + /// runs `refilter_and_audit` against the response and emits an + /// `AdapterFilterViolation` audit event per dropped row. Wired by + /// `registry::new_http` once `audit.nova` is provided. + pub fn with_audit(mut self, ctx: Arc) -> Self { + self.audit_ctx = Some(ctx); + self + } + + /// Apply response-side scope refiltering to a `PaginatedResponse`. + /// No-op when `audit_ctx` is None (pre-Step-14 adapters), preserving + /// the original response shape. When attached, partitions via + /// [`refilter_and_audit`] which fans out one `AdapterFilterViolation` + /// event per dropped row before returning the kept items. + /// + /// [`refilter_and_audit`]: crate::adapter::http::scope_refilter::refilter_and_audit + fn refilter_response( + &self, + resp: PaginatedResponse, + all_tenants: bool, + action_type: &str, + resource_kind: &str, + ) -> PaginatedResponse + where + T: crate::adapter::http::scope_refilter::ScopedItem, + { + let active = self + .audit_ctx + .as_ref() + .and_then(|ctx| ctx.scope_provider.current_project_id()); + let scope = RefilterScope::from_parts(active.as_deref(), all_tenants); + // correlation_id=0: list_* are not bound to a worker dispatch. + // See `NeutronHttpAdapter::refilter_response` for the rationale. + let kept = refilter_and_audit( + resp.items, + &scope, + self.audit_ctx.as_deref(), + action_type, + resource_kind, + 0, + ); + PaginatedResponse { + items: kept, + next_marker: resp.next_marker, + has_more: resp.has_more, + } } } @@ -226,7 +281,7 @@ impl NovaPort for NovaHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_server_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/servers/detail", &query, @@ -235,7 +290,8 @@ impl NovaPort for NovaHttpAdapter { (resp.servers, next) }, ) - .await + .await?; + Ok(self.refilter_response(resp, filter.all_tenants, "FetchServers", "server")) } async fn get_server(&self, server_id: &str) -> ApiResult { diff --git a/src/adapter/http/scope_refilter.rs b/src/adapter/http/scope_refilter.rs new file mode 100644 index 0000000..c48c193 --- /dev/null +++ b/src/adapter/http/scope_refilter.rs @@ -0,0 +1,812 @@ +//! BL-P2-085 Step 13a — cross-project response refilter (pure helper). +//! +//! Used by HTTP list adapters (Neutron Step 13b, Nova/Cinder Step 14) to +//! enforce project scoping on the response side. This is defense-in-depth +//! atop the server-side `tenant_id={scope}` injection wired in Step 12. +//! +//! Policy +//! - `all_tenants == true` (admin opt-out) → no-op, `(items, [])`. +//! - `all_tenants == false && active.is_none()` → no-op, `(items, [])`. +//! The caller has no ground truth to compare against; worker-side guard +//! already deny-blocks mutations in unscoped state via UnscopedFailSafe, +//! so emitting per-item events here would be noise. +//! - `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` (the +//! server returned a row we cannot prove belongs to the active scope). +//! +//! Emitting [`CrossProjectBlockEvent`] with reason +//! [`CrossProjectReason::AdapterFilterViolation`] per dropped item is the +//! caller's responsibility (Step 13b). The trait surfaces `resource_id` to +//! make that wiring ergonomic. When a dropped item's `tenant_id() == None` +//! (server returned a row without a project-id label), the caller MUST +//! still emit the event with the `tenant_id` field preserved as missing — +//! the audit chain depends on every drop being attributable. + +use crate::models::cinder::{Volume, VolumeSnapshot}; +use crate::models::neutron::{FloatingIp, Network, SecurityGroup}; +use crate::models::nova::Server; + +/// Minimal contract a list item must satisfy to participate in +/// project-scope refiltering. `tenant_id` returns `None` when the underlying +/// model lacks a project-id field on the wire (treated as fail-safe drop +/// under strict scoping). `resource_id` is consumed by the AdapterFilter- +/// Violation event builder to report which row was rejected. +pub trait ScopedItem { + /// Project-id label as returned by the upstream API. `None` means the + /// model has no project-id field on the wire (or the server omitted + /// it); under strict scoping such rows are dropped fail-safe. + fn tenant_id(&self) -> Option<&str>; + /// Stable identifier for audit reporting. `None` is tolerated for + /// models without a primary id — the AdapterFilterViolation event + /// will fall back to a placeholder rather than skipping the emit. + fn resource_id(&self) -> Option<&str>; +} + +/// Encodes the (active, all_tenants) invariant for [`refilter_by_scope`] in +/// three ctor-validated states. Constructed via [`RefilterScope::strict`], +/// [`RefilterScope::all_tenants`], [`RefilterScope::unscoped`], or +/// [`RefilterScope::from_parts`]; raw fields are kept private so an invalid +/// combination (e.g. `active=Some + all_tenants=true`) cannot be expressed. +#[derive(Debug, Clone, Copy)] +pub struct RefilterScope<'a> { + active: Option<&'a str>, + all_tenants: bool, +} + +impl<'a> RefilterScope<'a> { + /// Strict refilter: drop everything not matching `active`. + /// + /// `active` must be non-empty — an empty string would cause every row + /// to be dropped silently (since list models never carry + /// `tenant_id == ""`). The `debug_assert!` catches the caller bug in + /// dev builds; release builds rely on the [`from_parts`] empty-string + /// normalization (which is the only production caller). + /// + /// [`from_parts`]: RefilterScope::from_parts + pub fn strict(active: &'a str) -> Self { + debug_assert!( + !active.is_empty(), + "RefilterScope::strict requires a non-empty active project id — empty would drop all rows" + ); + Self { + active: Some(active), + all_tenants: false, + } + } + + /// Admin opt-out: keep every row regardless of project. + pub fn all_tenants() -> Self { + Self { + active: None, + all_tenants: true, + } + } + + /// No scope to compare against (worker-side guard handles mutations). + pub fn unscoped() -> Self { + Self { + active: None, + all_tenants: false, + } + } + + /// Adapter from the legacy 2-arg shape. Normalizes two corner cases so + /// the resulting scope always satisfies the ctor-validated invariant: + /// - `all_tenants=true` wins over `active` (cleared to None). + /// - `active=Some("")` is treated as `None` (unscoped) so an empty + /// project id from `scope_provider` cannot route into the + /// [`strict`] panic path. + /// + /// [`strict`]: RefilterScope::strict + pub fn from_parts(active: Option<&'a str>, all_tenants: bool) -> Self { + if all_tenants { + Self::all_tenants() + } else { + match active { + Some(a) if !a.is_empty() => Self::strict(a), + _ => Self::unscoped(), + } + } + } + + /// Active project id under strict scoping. `None` for `all_tenants` + /// or unscoped — callers should branch on this together with + /// [`is_all_tenants`] when reconstructing the policy. + /// + /// [`is_all_tenants`]: RefilterScope::is_all_tenants + pub fn active(&self) -> Option<&'a str> { + self.active + } + + /// `true` when the scope is the admin opt-out (every row kept). Always + /// implies [`active`] is `None`; the ctor invariant rules out the + /// `active=Some + all_tenants=true` combination. + /// + /// [`active`]: RefilterScope::active + pub fn is_all_tenants(&self) -> bool { + self.all_tenants + } +} + +/// Caller-provided sink for `AdapterFilterViolation` events. Step-14 adapter +/// audit contexts (Neutron/Nova/Cinder) implement this for any +/// `T: ScopedItem`, allowing [`refilter_and_audit`] to fan one event out +/// per dropped row colocated with the partition step. Generic over `T` so +/// each adapter context handles its native list-item type without erasing +/// `tenant_id` / `resource_id` to `&dyn ScopedItem`. +pub trait AuditEmitter { + /// Emit one `CrossProjectBlockEvent` with reason `AdapterFilterViolation` + /// per dropped row. Implementations MUST be no-op when `dropped` is + /// empty (callers rely on this to avoid touching the audit log on the + /// zero-violation path), and MUST attribute every dropped row — even + /// rows whose `tenant_id()` is `None` — so the audit chain stays + /// loss-less per the module-level contract. + fn emit_filter_violations( + &self, + dropped: &[T], + action_type: &str, + resource_kind: &str, + correlation_id: u64, + ); +} + +/// Partition `items` into `(kept, dropped)` according to the scope policy +/// described in the module-level docstring. The function is allocation- +/// minimal — `kept` is pre-sized to match `items`, and `dropped` only +/// allocates when at least one item is rejected. Do NOT replace this with +/// `Iterator::partition`: that pre-allocates both sides, which is wasteful +/// for the common path (large list, zero drops). +pub fn refilter_by_scope( + items: Vec, + scope: &RefilterScope<'_>, +) -> (Vec, Vec) { + if scope.is_all_tenants() { + return (items, Vec::new()); + } + let Some(active) = scope.active() else { + return (items, Vec::new()); + }; + let mut kept = Vec::with_capacity(items.len()); + let mut dropped = Vec::new(); + for item in items { + match item.tenant_id() { + Some(tid) if tid == active => kept.push(item), + _ => dropped.push(item), + } + } + (kept, dropped) +} + +/// Partition `items` via [`refilter_by_scope`] and, when `audit` is `Some` +/// and `dropped` is non-empty, fan one event out per dropped row through +/// `audit.emit_filter_violations`. Returns only `kept` because the +/// `dropped` set is consumed by the audit path; callers that need both +/// vectors should call [`refilter_by_scope`] directly. +/// +/// This colocates the partition with the audit emit so Step-14 adapters +/// (Nova/Cinder) can replace 8-line wrappers with a single call. The +/// generic `A` allows `Option<&NeutronAuditCtx>` callers to avoid +/// `dyn AuditEmitter` erasure (each adapter has exactly one audit ctx +/// type at compile time). +pub fn refilter_and_audit( + items: Vec, + scope: &RefilterScope<'_>, + audit: Option<&A>, + action_type: &str, + resource_kind: &str, + correlation_id: u64, +) -> Vec +where + T: ScopedItem, + A: AuditEmitter + ?Sized, +{ + let (kept, dropped) = refilter_by_scope(items, scope); + if !dropped.is_empty() + && let Some(a) = audit + { + a.emit_filter_violations(&dropped, action_type, resource_kind, correlation_id); + } + kept +} + +// --- BL-P2-085 Step 13b: ScopedItem impls for Neutron list models --- +// All three models share the same shape: `id: String` (always present) and +// `tenant_id: Option` (server may omit under unusual configurations, +// in which case strict refiltering drops the row fail-safe). + +impl ScopedItem for Network { + fn tenant_id(&self) -> Option<&str> { + self.tenant_id.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } +} + +impl ScopedItem for SecurityGroup { + fn tenant_id(&self) -> Option<&str> { + self.tenant_id.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } +} + +impl ScopedItem for FloatingIp { + fn tenant_id(&self) -> Option<&str> { + self.tenant_id.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } +} + +// --- BL-P2-085 Step 14: ScopedItem impls for Nova/Cinder list models --- +// `Server.tenant_id: Option` is direct; `Volume.tenant_id` and +// `VolumeSnapshot.tenant_id` are Rust-side renames of the upstream +// `os-vol-tenant-attr:tenant_id` / `os-extended-snapshot-attributes: +// project_id` wire fields, so the impl shape is identical. + +impl ScopedItem for Server { + fn tenant_id(&self) -> Option<&str> { + self.tenant_id.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } +} + +impl ScopedItem for Volume { + fn tenant_id(&self) -> Option<&str> { + self.tenant_id.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } +} + +impl ScopedItem for VolumeSnapshot { + fn tenant_id(&self) -> Option<&str> { + self.tenant_id.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::neutron::{FloatingIp, Network, SecurityGroup}; + + /// Test fixture mirroring the minimal shape required for refilter + /// (id + optional tenant). Real impls (Network / SecurityGroup / + /// FloatingIp / Server / Volume / Snapshot) land in Step 13b/14. + #[derive(Debug, PartialEq, Eq)] + struct FakeItem { + id: &'static str, + tenant: Option<&'static str>, + } + + impl ScopedItem for FakeItem { + fn tenant_id(&self) -> Option<&str> { + self.tenant + } + fn resource_id(&self) -> Option<&str> { + Some(self.id) + } + } + + #[test] + fn test_refilter_drops_cross_project_items_when_scope_strict() { + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + FakeItem { + id: "c", + tenant: Some("A"), + }, + ]; + let (kept, dropped) = refilter_by_scope(items, &RefilterScope::strict("A")); + assert_eq!(kept.len(), 2, "two A-tenant items should be kept"); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].id, "b"); + } + + #[test] + fn test_refilter_keeps_all_when_all_tenants_true() { + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + ]; + let (kept, dropped) = refilter_by_scope(items, &RefilterScope::all_tenants()); + assert_eq!(kept.len(), 2, "all_tenants=true must short-circuit"); + assert!(dropped.is_empty()); + } + + #[test] + fn test_refilter_keeps_active_scope_items() { + let items = vec![ + FakeItem { + id: "a1", + tenant: Some("A"), + }, + FakeItem { + id: "a2", + tenant: Some("A"), + }, + ]; + let (kept, dropped) = refilter_by_scope(items, &RefilterScope::strict("A")); + assert_eq!(kept.len(), 2); + assert!(dropped.is_empty()); + } + + #[test] + fn test_refilter_drops_items_with_missing_tenant_id_when_strict() { + // Fail-safe: if the server returned an item with no tenant_id, we + // cannot prove it belongs to the active scope, so we drop it. + let items = vec![ + FakeItem { + id: "x", + tenant: None, + }, + FakeItem { + id: "a", + tenant: Some("A"), + }, + ]; + let (kept, dropped) = refilter_by_scope(items, &RefilterScope::strict("A")); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "a"); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].id, "x"); + } + + #[test] + fn test_refilter_no_op_when_active_none() { + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + ]; + let (kept, dropped) = refilter_by_scope(items, &RefilterScope::unscoped()); + assert_eq!(kept.len(), 2, "active=None must short-circuit (no-op)"); + assert!(dropped.is_empty()); + } + + // --- BL-P2-085 Step-14-precedent-refactor-cycle (refactor-1) --- + // RefilterScope encodes the (active, all_tenants) invariant in three + // ctor-validated states: strict / all_tenants / unscoped. Replaces the + // previous 2-arg call shape so 5+ Step-14 callers cannot accidentally + // pass active=Some + all_tenants=true (a meaningless combination). + + #[test] + fn test_refilter_scope_strict_drops_cross_project_items() { + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + ]; + let scope = RefilterScope::strict("A"); + let (kept, dropped) = refilter_by_scope(items, &scope); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "a"); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].id, "b"); + } + + #[test] + fn test_refilter_scope_all_tenants_short_circuits() { + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + ]; + let scope = RefilterScope::all_tenants(); + let (kept, dropped) = refilter_by_scope(items, &scope); + assert_eq!(kept.len(), 2); + assert!(dropped.is_empty()); + } + + #[test] + fn test_refilter_scope_unscoped_short_circuits() { + let items = vec![FakeItem { + id: "a", + tenant: Some("A"), + }]; + let scope = RefilterScope::unscoped(); + let (kept, dropped) = refilter_by_scope(items, &scope); + assert_eq!(kept.len(), 1); + assert!(dropped.is_empty()); + } + + #[test] + fn test_refilter_scope_from_parts_normalizes_invalid_combinations() { + let strict = RefilterScope::from_parts(Some("A"), false); + assert_eq!(strict.active(), Some("A")); + assert!(!strict.is_all_tenants()); + + let admin = RefilterScope::from_parts(Some("A"), true); + assert_eq!(admin.active(), None, "all_tenants=true must clear active"); + assert!(admin.is_all_tenants()); + + let unscoped = RefilterScope::from_parts(None, false); + assert_eq!(unscoped.active(), None); + assert!(!unscoped.is_all_tenants()); + } + + // --- cargo-review SECURITY follow-ups (Sugg #1 + #2) --- + // Guard against the two latent footguns the SECURITY reviewer raised: + // #1 `strict("")` would silently drop every row (tenant_id == "") + // #2 `from_parts(Some(""), false)` would also reach strict("") via + // the legacy 2-arg adapter path. + + #[test] + #[should_panic(expected = "non-empty active")] + fn test_refilter_scope_strict_panics_on_empty_active() { + // `strict("")` is a caller bug — every row's `tenant_id == ""` test + // would fail, dropping all data. Caught in debug builds. + let _ = RefilterScope::strict(""); + } + + #[test] + fn test_refilter_scope_from_parts_treats_empty_string_as_unscoped() { + // Fail-safe: if `scope_provider.current_project_id()` ever yields + // `Some("")` (unscoped session, partial token, etc.), the legacy + // 2-arg path must NOT route into the panic above. Normalize to + // unscoped (refilter no-op) so the worker-side guard handles it. + let scope = RefilterScope::from_parts(Some(""), false); + assert_eq!( + scope.active(), + None, + "empty string must be normalized to None" + ); + assert!(!scope.is_all_tenants()); + } + + // --- BL-P2-085 Step-14-precedent-refactor-cycle (refactor-2) --- + // `refilter_and_audit` colocates the partition with the per-row audit + // emit. `AuditEmitter` is the trait Step-14 adapter contexts + // (NeutronAuditCtx / NovaAuditCtx / CinderAuditCtx) implement so the + // free fn stays generic over the audit sink. + + use std::cell::RefCell; + + /// Test double for `AuditEmitter` — records (dropped_len, action_type, + /// resource_kind, correlation_id) per call so assertions can verify + /// the emit was forwarded with the right arguments. + struct CountingEmitter { + calls: RefCell>, + } + + impl CountingEmitter { + fn new() -> Self { + Self { + calls: RefCell::new(Vec::new()), + } + } + } + + impl AuditEmitter for CountingEmitter { + fn emit_filter_violations( + &self, + dropped: &[FakeItem], + action_type: &str, + resource_kind: &str, + correlation_id: u64, + ) { + self.calls.borrow_mut().push(( + dropped.len(), + action_type.to_string(), + resource_kind.to_string(), + correlation_id, + )); + } + } + + #[test] + fn test_refilter_and_audit_emits_when_dropped_nonempty() { + let emitter = CountingEmitter::new(); + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + ]; + let kept = refilter_and_audit( + items, + &RefilterScope::strict("A"), + Some(&emitter), + "FetchTest", + "test_resource", + 42, + ); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "a"); + let calls = emitter.calls.borrow(); + assert_eq!(calls.len(), 1, "emitter should be called exactly once"); + assert_eq!(calls[0].0, 1, "dropped_len=1"); + assert_eq!(calls[0].1, "FetchTest"); + assert_eq!(calls[0].2, "test_resource"); + assert_eq!(calls[0].3, 42); + } + + #[test] + fn test_refilter_and_audit_skips_emit_when_audit_none() { + let items = vec![ + FakeItem { + id: "a", + tenant: Some("A"), + }, + FakeItem { + id: "b", + tenant: Some("B"), + }, + ]; + let kept = refilter_and_audit::<_, CountingEmitter>( + items, + &RefilterScope::strict("A"), + None, + "FetchTest", + "test_resource", + 42, + ); + assert_eq!(kept.len(), 1, "kept must still be filtered when audit=None"); + } + + // --- BL-P2-085 Step-14-precedent-refactor-cycle (refactor-3) --- + // Trait rename `HasTenantId` → `ScopedItem` because the contract is + // "this row participates in scope comparison", not merely "has a + // tenant_id field". The new name accommodates Step 14 models like + // Cinder that may carry `project_id` instead of `tenant_id`. + #[test] + fn test_scoped_item_trait_used_for_refilter_signature() { + fn _bound() {} + _bound::(); + } + + #[test] + fn test_refilter_and_audit_skips_emit_when_dropped_empty() { + let emitter = CountingEmitter::new(); + let items = vec![FakeItem { + id: "a", + tenant: Some("A"), + }]; + let kept = refilter_and_audit( + items, + &RefilterScope::strict("A"), + Some(&emitter), + "FetchTest", + "test_resource", + 42, + ); + assert_eq!(kept.len(), 1); + assert!( + emitter.calls.borrow().is_empty(), + "emitter must not be called when dropped is empty" + ); + } + + // --- BL-P2-085 Step 13b: ScopedItem impl for Neutron models --- + // These tests assert that Network / SecurityGroup / FloatingIp implement + // ScopedItem in a way that maps `tenant_id: Option` → + // `tenant_id()` and `id: String` → `resource_id()`. + + fn sample_network(id: &str, tenant: Option<&str>) -> Network { + Network { + id: id.to_string(), + name: "n".to_string(), + status: "ACTIVE".to_string(), + description: None, + admin_state_up: true, + external: false, + shared: false, + mtu: None, + port_security_enabled: None, + subnets: Vec::new(), + provider_network_type: None, + provider_physical_network: None, + provider_segmentation_id: None, + tenant_id: tenant.map(str::to_string), + } + } + + fn sample_security_group(id: &str, tenant: Option<&str>) -> SecurityGroup { + SecurityGroup { + id: id.to_string(), + name: "sg".to_string(), + description: None, + security_group_rules: Vec::new(), + tenant_id: tenant.map(str::to_string), + } + } + + fn sample_floating_ip(id: &str, tenant: Option<&str>) -> FloatingIp { + FloatingIp { + id: id.to_string(), + floating_ip_address: "203.0.113.1".to_string(), + status: "ACTIVE".to_string(), + port_id: None, + floating_network_id: "ext".to_string(), + fixed_ip_address: None, + router_id: None, + tenant_id: tenant.map(str::to_string), + } + } + + #[test] + fn test_network_has_tenant_id_returns_some_when_present() { + let net = sample_network("net-1", Some("proj-A")); + assert_eq!(net.tenant_id(), Some("proj-A")); + assert_eq!(net.resource_id(), Some("net-1")); + } + + #[test] + fn test_network_has_tenant_id_returns_none_when_absent() { + let net = sample_network("net-2", None); + assert_eq!(net.tenant_id(), None); + assert_eq!(net.resource_id(), Some("net-2")); + } + + #[test] + fn test_security_group_has_tenant_id_returns_some_when_present() { + let sg = sample_security_group("sg-1", Some("proj-B")); + assert_eq!(sg.tenant_id(), Some("proj-B")); + assert_eq!(sg.resource_id(), Some("sg-1")); + } + + #[test] + fn test_floating_ip_has_tenant_id_returns_some_when_present() { + let fip = sample_floating_ip("fip-1", Some("proj-C")); + assert_eq!(fip.tenant_id(), Some("proj-C")); + assert_eq!(fip.resource_id(), Some("fip-1")); + } + + #[test] + fn test_floating_ip_has_tenant_id_returns_none_when_absent() { + let fip = sample_floating_ip("fip-2", None); + assert_eq!(fip.tenant_id(), None); + assert_eq!(fip.resource_id(), Some("fip-2")); + } + + // --- BL-P2-085 Step 14: ScopedItem impls for Nova/Cinder models --- + // Server (nova): `tenant_id: Option` direct. + // Volume (cinder): wire field `os-vol-tenant-attr:tenant_id` renamed + // to `tenant_id` in the struct. + // VolumeSnapshot (cinder): wire field + // `os-extended-snapshot-attributes:project_id` renamed to + // `tenant_id` in the struct — same Rust-side shape as Neutron and + // Nova, so the impl is identical. + + fn sample_server(id: &str, tenant: Option<&str>) -> crate::models::nova::Server { + crate::models::nova::Server { + id: id.to_string(), + name: "srv".to_string(), + status: "ACTIVE".to_string(), + addresses: std::collections::HashMap::new(), + flavor: crate::models::nova::FlavorRef { + id: "f-1".to_string(), + original_name: None, + vcpus: None, + ram: None, + disk: None, + }, + image: None, + key_name: None, + availability_zone: None, + created: "2026-01-01T00:00:00Z".to_string(), + updated: None, + tenant_id: tenant.map(str::to_string), + host_id: None, + host: None, + volumes_attached: Vec::new(), + security_groups: Vec::new(), + } + } + + fn sample_volume(id: &str, tenant: Option<&str>) -> crate::models::cinder::Volume { + crate::models::cinder::Volume { + id: id.to_string(), + name: None, + description: None, + status: "available".to_string(), + size: 1, + volume_type: None, + encrypted: false, + bootable: "false".to_string(), + attachments: Vec::new(), + availability_zone: None, + created_at: None, + tenant_id: tenant.map(str::to_string), + } + } + + fn sample_volume_snapshot( + id: &str, + tenant: Option<&str>, + ) -> crate::models::cinder::VolumeSnapshot { + crate::models::cinder::VolumeSnapshot { + id: id.to_string(), + name: None, + status: "available".to_string(), + size: 1, + volume_id: "vol-1".to_string(), + created_at: None, + tenant_id: tenant.map(str::to_string), + } + } + + #[test] + fn test_server_has_scoped_item_returns_some_when_present() { + let srv = sample_server("srv-1", Some("proj-A")); + assert_eq!(srv.tenant_id(), Some("proj-A")); + assert_eq!(srv.resource_id(), Some("srv-1")); + } + + #[test] + fn test_server_has_scoped_item_returns_none_when_absent() { + let srv = sample_server("srv-2", None); + assert_eq!(srv.tenant_id(), None); + assert_eq!(srv.resource_id(), Some("srv-2")); + } + + #[test] + fn test_volume_has_scoped_item_returns_some_when_present() { + let vol = sample_volume("vol-1", Some("proj-B")); + assert_eq!(vol.tenant_id(), Some("proj-B")); + assert_eq!(vol.resource_id(), Some("vol-1")); + } + + #[test] + fn test_volume_has_scoped_item_returns_none_when_absent() { + let vol = sample_volume("vol-2", None); + assert_eq!(vol.tenant_id(), None); + assert_eq!(vol.resource_id(), Some("vol-2")); + } + + #[test] + fn test_volume_snapshot_has_scoped_item_returns_some_when_present() { + let snap = sample_volume_snapshot("snap-1", Some("proj-C")); + assert_eq!(snap.tenant_id(), Some("proj-C")); + assert_eq!(snap.resource_id(), Some("snap-1")); + } + + #[test] + fn test_volume_snapshot_has_scoped_item_returns_none_when_absent() { + let snap = sample_volume_snapshot("snap-2", None); + assert_eq!(snap.tenant_id(), None); + assert_eq!(snap.resource_id(), Some("snap-2")); + } +} diff --git a/src/adapter/registry.rs b/src/adapter/registry.rs index 6dd5b98..8819c06 100644 --- a/src/adapter/registry.rs +++ b/src/adapter/registry.rs @@ -5,6 +5,7 @@ use crate::adapter::http::cinder::CinderHttpAdapter; use crate::adapter::http::glance::GlanceHttpAdapter; use crate::adapter::http::keystone::KeystoneHttpAdapter; use crate::adapter::http::neutron::NeutronHttpAdapter; +use crate::adapter::http::neutron_audit::AdapterAuditConfig; use crate::adapter::http::nova::NovaHttpAdapter; use crate::port::auth::AuthProvider; use crate::port::cinder::CinderPort; @@ -44,7 +45,17 @@ impl AdapterRegistry { } /// Create all HTTP adapters from the given auth provider and region. - pub fn new_http(auth: Arc, region: Option) -> Result { + /// + /// `audit` (BL-P2-085 Step-14-precedent-refactor-3) bundles per-service + /// audit contexts. Step 13b wired Neutron; Step 14 wires Nova and + /// Cinder. Pass `AdapterAuditConfig::default()` for mock registries + /// and integration tests that don't care about audit emission; each + /// adapter then behaves as a pre-refilter passthrough. + pub fn new_http( + auth: Arc, + region: Option, + audit: AdapterAuditConfig, + ) -> Result { let nova_base = Self::make_base(auth.clone(), "compute", region.clone())?; let neutron_base = Self::make_base(auth.clone(), "network", region.clone())?; let cinder_base = Self::make_base(auth.clone(), "block-storage", region.clone())?; @@ -59,10 +70,23 @@ impl AdapterRegistry { keystone_base.clone(), ]; + let mut neutron = NeutronHttpAdapter::from_base(neutron_base); + if let Some(ctx) = audit.neutron { + neutron = neutron.with_audit(ctx); + } + let mut nova = NovaHttpAdapter::from_base(nova_base); + if let Some(ctx) = audit.nova { + nova = nova.with_audit(ctx); + } + let mut cinder = CinderHttpAdapter::from_base(cinder_base); + if let Some(ctx) = audit.cinder { + cinder = cinder.with_audit(ctx); + } + Ok(Self { - nova: Arc::new(NovaHttpAdapter::from_base(nova_base)), - neutron: Arc::new(NeutronHttpAdapter::from_base(neutron_base)), - cinder: Arc::new(CinderHttpAdapter::from_base(cinder_base)), + nova: Arc::new(nova), + neutron: Arc::new(neutron), + cinder: Arc::new(cinder), glance: Arc::new(GlanceHttpAdapter::from_base(glance_base)), keystone: Arc::new(KeystoneHttpAdapter::from_base(keystone_base)), http_caches, diff --git a/src/app.rs b/src/app.rs index bd89375..92a261d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -77,7 +77,11 @@ pub struct App { activity_log: ActivityLog, activity_popup: ActivityLogPopup, show_activity_log: bool, - audit_logger: Option, + audit_logger: Option>, + /// Phase 7 폴리싱: shared with the background worker so a runtime + /// cloud-switch updates the next `CrossProjectBlockEvent`'s `cloud` + /// field. Wired post-construction via `set_actor_ctx`. + actor_ctx: Option>>, /// Command bar input widget (`:`-triggered). Paired with `command_parser`. pub(crate) input_bar: InputBar, pub(crate) command_parser: CommandParser, @@ -124,6 +128,7 @@ impl App { activity_popup: ActivityLogPopup::new(), show_activity_log: false, audit_logger, + actor_ctx: None, input_bar: InputBar::new(), command_parser, context_indicator: ContextIndicator::new(std::time::Duration::from_secs(2)), @@ -169,6 +174,7 @@ impl App { activity_popup: ActivityLogPopup::new(), show_activity_log: false, audit_logger, + actor_ctx: None, input_bar: InputBar::new(), command_parser, context_indicator: ContextIndicator::new(std::time::Duration::from_secs(2)), @@ -201,7 +207,7 @@ impl App { /// Inject an audit logger for testing. #[cfg(test)] pub fn set_audit_logger(&mut self, logger: AuditLogger) { - self.audit_logger = Some(logger); + self.audit_logger = Some(Arc::new(logger)); } /// Handle key input. Returns true if a re-render is needed. @@ -639,6 +645,15 @@ 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. + if let Some(ref ctx) = self.actor_ctx + && let Ok(mut guard) = ctx.write() + { + guard.cloud = target.cloud.clone(); + } self.context_indicator.set_target(target, true); for component in self.components.values_mut() { component.on_context_changed(); @@ -758,7 +773,10 @@ impl App { } /// Initialize audit logger. Returns None on failure (non-fatal). - fn init_audit_logger() -> Option { + /// Wrapped in `Arc` so the worker (BL-P2-085 Step 11b) can share the same + /// instance — two `AuditLogger` instances on the same path would interleave + /// writes through their independent `BufWriter`s. + fn init_audit_logger() -> Option> { #[cfg(test)] { // In tests, do not create audit logger by default @@ -768,7 +786,7 @@ impl App { { let path = crate::config::nexttui_config_dir().join("audit.log"); match AuditLogger::new(path) { - Ok(logger) => Some(logger), + Ok(logger) => Some(Arc::new(logger)), Err(e) => { tracing::warn!("Failed to initialize audit logger: {e}"); None @@ -777,6 +795,20 @@ impl App { } } + /// FR2 Step 11b: handle to the audit logger so the worker can share the + /// same `Arc` and emit `CrossProjectBlockEvent` entries through it. + pub fn audit_logger_arc(&self) -> Option> { + self.audit_logger.clone() + } + + /// Phase 7 폴리싱: install the shared actor context so `ContextChanged` + /// updates land in the worker's next audit entry. The Arc is held by + /// both the worker and this `App`; mutations through the `RwLock` are + /// visible to both sides without re-spawning the worker. + pub fn set_actor_ctx(&mut self, ctx: Arc>) { + self.actor_ctx = Some(ctx); + } + /// Record a CUD event to the audit log. Errors are logged as warnings, never propagated. fn record_audit(&self, event: &AppEvent) { let Some(ref logger) = self.audit_logger else { @@ -1425,6 +1457,16 @@ impl App { operation.clone(), String::new(), ), + // BL-P2-085 Step 11c: cross-project block surfaced from the worker. + // Worker has already written the structured audit entry; the toast + // is the user-visible counterpart and uses Error level (parity with + // PermissionDenied) so it inherits the longer Error TTL. + AppEvent::CrossProjectBlocked { reason, action } => ( + format!("Cross-project block: {action} ({reason})"), + ToastLevel::Error, + action.clone(), + String::new(), + ), // Data loaded / system events — no toast or activity log _ => return, }; @@ -3059,6 +3101,29 @@ mod tests { assert!(entry.message.contains("quota exceeded")); } + // --- BL-P2-085 Step 11c: cross-project block toast --- + + #[test] + fn test_generate_toast_for_cross_project_blocked_pushes_error() { + let mut app = make_app(); + app.handle_event(AppEvent::CrossProjectBlocked { + reason: "origin_scope_mismatch".into(), + action: "DeleteServer".into(), + }); + assert_eq!(app.activity_log.entries().len(), 1); + let entry = &app.activity_log.entries()[0]; + assert!( + !entry.success, + "cross-project block must surface as a failure in the activity log", + ); + assert_eq!(entry.operation, "DeleteServer"); + assert!( + entry.message.contains("origin_scope_mismatch"), + "toast message must include the reason: {msg}", + msg = entry.message, + ); + } + #[test] fn test_error_badge_count_reflects_activity_log() { let mut app = make_app(); diff --git a/src/context/action_channel.rs b/src/context/action_channel.rs index c520bf5..578d74d 100644 --- a/src/context/action_channel.rs +++ b/src/context/action_channel.rs @@ -14,87 +14,147 @@ use std::sync::Arc; use tokio::sync::mpsc::{self, error::SendError, error::TryRecvError}; -use crate::action::Action; +use crate::action::{Action, DispatchedAction}; +use crate::infra::rbac::RbacGuard; use super::epoch::ContextEpoch; use super::versioned::VersionedEvent; +/// Read-only view of the currently active project scope, supplied to +/// `ActionSender` for FR2 origin stamping (BL-P2-085 Step 8). +/// +/// `ActionSender` invokes `current_project_id()` at *send time* (not +/// construction time) so the stamp reflects the scope live at the moment a +/// mutating action is dispatched. Implementations must therefore read live +/// state — never a captured snapshot. +/// +/// `Send + Sync` lets the sender be cloned across tasks (Tokio worker fanout) +/// while still calling the provider concurrently. The trait object form +/// (`Arc`) is what `ActionSender` stores in Step 9. +pub trait ScopeProvider: Send + Sync { + /// Active project_id at the moment of the call. `None` means "unscoped" + /// (e.g. before first auth) — the caller decides how to treat it + /// (FR2 stamping uses `None` origin → no origin guard, no audit emit). + fn current_project_id(&self) -> Option; +} + +// Implementation lives on `RbacGuard` itself (not `Arc`) so an +// `Arc` value coerces to `Arc` via the +// standard unsized coercion (T: Trait ⇒ Arc → Arc). +impl ScopeProvider for RbacGuard { + fn current_project_id(&self) -> Option { + self.project_id() + } +} + +/// Module-facing facade over the worker action channel. +/// +/// Two responsibilities, both done at *send time*: +/// 1. Stamp the current `ContextEpoch` so the worker can drop stale work after +/// a switch (BL-P2-031 Unit 4). +/// 2. Stamp the `origin_project_id` for mutation Actions, sourced live from +/// `scope_provider`, so the worker (Step 11) can reject TOCTOU mismatches +/// where a mutation queued under one scope is consumed under another +/// (BL-P2-085 FR2). #[derive(Clone)] pub struct ActionSender { - tx: mpsc::UnboundedSender>, + tx: mpsc::UnboundedSender>, epoch: Arc, + scope_provider: Arc, } /// Receiver-side convenience wrapper that unwraps the `VersionedEvent` /// envelope for callers that do not need the epoch (typically tests). /// -/// The worker uses the raw `mpsc::UnboundedReceiver>` -/// because it needs the epoch to drop stale actions; tests that only care -/// about the [`Action`] payload should use [`ActionReceiver`] so existing -/// `recv().await.unwrap()` assertions keep working. +/// The worker uses the raw +/// `mpsc::UnboundedReceiver>` because it +/// needs both the epoch (drop stale actions) and the `origin_project_id` +/// (FR2 mutation guard, Step 11). Tests that only care about the [`Action`] +/// payload should use [`ActionReceiver`] so existing `recv().await.unwrap()` +/// assertions keep working — the receiver internally strips the +/// `DispatchedAction` envelope and yields the underlying `Action`. pub struct ActionReceiver { - rx: mpsc::UnboundedReceiver>, + rx: mpsc::UnboundedReceiver>, } impl ActionReceiver { - pub fn new(rx: mpsc::UnboundedReceiver>) -> Self { + pub fn new(rx: mpsc::UnboundedReceiver>) -> Self { Self { rx } } pub async fn recv(&mut self) -> Option { - self.rx.recv().await.map(VersionedEvent::into_inner) + self.rx.recv().await.map(|env| env.into_inner().action) } pub fn try_recv(&mut self) -> Result { - self.rx.try_recv().map(VersionedEvent::into_inner) + self.rx.try_recv().map(|env| env.into_inner().action) } pub fn close(&mut self) { self.rx.close(); } - pub fn into_inner(self) -> mpsc::UnboundedReceiver> { + pub fn into_inner(self) -> mpsc::UnboundedReceiver> { self.rx } } /// Create a paired sender/receiver for tests. The sender stamps with a -/// fresh `ContextEpoch` that starts at 0; tests that want to simulate a -/// bumped epoch can call `.bump()` on the epoch handle returned alongside. +/// fresh `ContextEpoch` that starts at 0 and an unscoped `RbacGuard` as the +/// scope provider (always returns `None` origin) — tests that want to +/// observe FR2 stamping should construct an `ActionSender` manually with a +/// configured `Arc` (see `test_sender_stamps_mutation_with_current_scope`). pub fn test_action_channel() -> (ActionSender, ActionReceiver) { let (tx, rx) = mpsc::unbounded_channel(); let epoch = Arc::new(ContextEpoch::new()); - (ActionSender::new(tx, epoch), ActionReceiver::new(rx)) + let scope: Arc = Arc::new(RbacGuard::new()); + (ActionSender::new(tx, epoch, scope), ActionReceiver::new(rx)) } impl ActionSender { pub fn new( - tx: mpsc::UnboundedSender>, + tx: mpsc::UnboundedSender>, epoch: Arc, + scope_provider: Arc, ) -> Self { - Self { tx, epoch } + Self { + tx, + epoch, + scope_provider, + } } - /// Stamp `action` with the current epoch and forward it to the worker. - /// Signature mirrors `UnboundedSender::send` so existing call sites - /// compile unchanged. + /// Wrap `action` in a [`DispatchedAction`] (stamping `origin_project_id` + /// for mutations from the live scope provider) and forward to the worker + /// via a [`VersionedEvent`] carrying the current epoch. /// - /// The `Err` variant is ~176 bytes because it carries the whole - /// `VersionedEvent`. Boxing it (or changing `Action`'s + /// The `Action` signature is preserved for the ~100 module call sites; + /// scope stamping is a hidden side-channel concern of the sender. + /// + /// The `Err` variant is ~176+ bytes because it carries the whole + /// `VersionedEvent`. Boxing it (or changing `Action`'s /// representation) would touch every send site in the codebase and /// needs benchmark-based justification — tracked under BL-P2-060. #[allow( clippy::result_large_err, reason = "tracked by BL-P2-060 — pending bench-based boxing decision" )] - pub fn send(&self, action: Action) -> Result<(), SendError>> { + pub fn send(&self, action: Action) -> Result<(), SendError>> { + let dispatched = if crate::worker::action_is_mutation(&action) { + match self.scope_provider.current_project_id() { + Some(project_id) => DispatchedAction::stamped(action, project_id), + None => DispatchedAction::unstamped(action), + } + } else { + DispatchedAction::unstamped(action) + }; self.tx - .send(VersionedEvent::new(action, self.epoch.current())) + .send(VersionedEvent::new(dispatched, self.epoch.current())) } /// Exposes the underlying raw sender. Used by the few sites that need /// to forward a pre-stamped envelope (e.g. replay from a queued event). - pub fn raw(&self) -> &mpsc::UnboundedSender> { + pub fn raw(&self) -> &mpsc::UnboundedSender> { &self.tx } @@ -110,12 +170,142 @@ impl ActionSender { #[cfg(test)] mod tests { use super::*; + use crate::infra::rbac::RbacGuard; + use crate::port::types::TokenRole; + + fn role(name: &str) -> TokenRole { + TokenRole { + id: format!("{name}-id"), + name: name.to_string(), + } + } + + // --- BL-P2-085 Step 8: ScopeProvider trait --- + + #[test] + fn test_scope_provider_returns_current_project_id() { + let guard = Arc::new(RbacGuard::new()); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + // Auto-deref: Arc → &RbacGuard → ScopeProvider method. + assert_eq!( + guard.current_project_id(), + Some("proj-A".to_string()), + "Arc must read project_id() via the impl on RbacGuard" + ); + // Coercion to trait object also works (used by ActionSender). + let dyn_scope: Arc = guard; + assert_eq!(dyn_scope.current_project_id(), Some("proj-A".to_string())); + } + + #[test] + fn test_scope_provider_returns_none_when_unscoped() { + let guard: Arc = Arc::new(RbacGuard::new()); + assert_eq!( + guard.current_project_id(), + None, + "Unscoped guard must yield None — caller (ActionSender) treats as no origin stamp" + ); + } + + // --- BL-P2-085 Step 9: ActionSender FR2 origin stamping --- + + #[tokio::test] + async fn test_sender_stamps_mutation_with_current_scope() { + // FR2: ActionSender stamps mutation Actions with the live project_id + // at send time. Worker (Step 11) compares stamp to active scope. + let (raw_tx, mut raw_rx) = mpsc::unbounded_channel(); + let epoch = Arc::new(ContextEpoch::new()); + let guard = Arc::new(RbacGuard::new()); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + let scope: Arc = guard; + let sender = ActionSender::new(raw_tx, epoch, scope); + + sender + .send(Action::DeleteServer { + id: "srv-1".into(), + name: "web".into(), + }) + .unwrap(); + + let envelope = raw_rx.recv().await.unwrap(); + let dispatched = envelope.into_inner(); + assert_eq!( + dispatched.origin_project_id, + Some("proj-A".to_string()), + "mutation must be stamped with current scope project_id" + ); + assert!( + matches!(dispatched.action, Action::DeleteServer { .. }), + "action payload preserved" + ); + } + + #[tokio::test] + async fn test_sender_leaves_readonly_unstamped() { + // FR2: read-only Actions carry origin=None and bypass the worker guard. + let (raw_tx, mut raw_rx) = mpsc::unbounded_channel(); + let epoch = Arc::new(ContextEpoch::new()); + let guard = Arc::new(RbacGuard::new()); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + let scope: Arc = guard; + let sender = ActionSender::new(raw_tx, epoch, scope); + + sender.send(Action::FetchServers).unwrap(); + + let envelope = raw_rx.recv().await.unwrap(); + let dispatched = envelope.into_inner(); + assert_eq!( + dispatched.origin_project_id, None, + "read-only must not be stamped" + ); + } + + #[tokio::test] + async fn test_sender_handles_unscoped_provider_returns_none_origin() { + // Defensive: even mutation Actions get origin=None when scope is unset + // (pre-auth path). The worker treats None as "no FR2 guard" — caller + // is expected to also gate the Action separately during pre-auth. + let (raw_tx, mut raw_rx) = mpsc::unbounded_channel(); + let epoch = Arc::new(ContextEpoch::new()); + let guard: Arc = Arc::new(RbacGuard::new()); // no roles, no scope + let sender = ActionSender::new(raw_tx, epoch, guard); + + sender + .send(Action::DeleteServer { + id: "srv-1".into(), + name: "web".into(), + }) + .unwrap(); + + let envelope = raw_rx.recv().await.unwrap(); + let dispatched = envelope.into_inner(); + assert_eq!( + dispatched.origin_project_id, None, + "unscoped provider must yield None origin even for mutations" + ); + } + + #[test] + fn test_scope_provider_reflects_post_update_change() { + // FR2 invariant: ActionSender stamps using *current* scope at send time. + // Therefore ScopeProvider must read live state, not a captured snapshot. + let guard = Arc::new(RbacGuard::new()); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + assert_eq!(guard.current_project_id(), Some("proj-A".to_string())); + guard.update_roles(vec![role("admin")], Some("proj-B".into())); + assert_eq!( + guard.current_project_id(), + Some("proj-B".to_string()), + "Provider must reflect live state for FR2 stamping correctness" + ); + } #[tokio::test] async fn send_stamps_with_current_epoch() { let (tx, mut rx) = mpsc::unbounded_channel(); let epoch = Arc::new(ContextEpoch::new()); - let sender = ActionSender::new(tx, epoch.clone()); + let scope: Arc = Arc::new(RbacGuard::new()); + let sender = ActionSender::new(tx, epoch.clone(), scope); sender.send(Action::Quit).unwrap(); let ev = rx.recv().await.unwrap(); @@ -132,9 +322,10 @@ mod tests { fn sender_is_cheaply_cloneable() { let (tx, _rx) = mpsc::unbounded_channel(); let epoch = Arc::new(ContextEpoch::new()); - let a = ActionSender::new(tx, epoch); + let scope: Arc = Arc::new(RbacGuard::new()); + let a = ActionSender::new(tx, epoch, scope); let b = a.clone(); - // Both should point at the same epoch/tx. + // Both should point at the same epoch/tx/scope_provider. a.send(Action::Quit).unwrap(); b.send(Action::Quit).unwrap(); } diff --git a/src/error.rs b/src/error.rs index fc6215c..229ad34 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use thiserror::Error; +use crate::infra::cross_project_guard::{CrossProjectReason, GuardLayer}; + #[derive(Error, Debug)] #[non_exhaustive] pub enum AppError { @@ -38,6 +40,16 @@ pub enum AppError { source: std::io::Error, }, + #[error( + "Cross-project operation blocked: {r} (layer: {l})", + r = reason.as_str(), + l = guard_layer.as_str() + )] + CrossProjectBlocked { + reason: CrossProjectReason, + guard_layer: GuardLayer, + }, + #[error("{0}")] Other(String), } @@ -47,6 +59,49 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; + use crate::infra::cross_project_guard::{CrossProjectReason, GuardLayer}; + + #[test] + fn test_cross_project_blocked_error_display() { + let err = AppError::CrossProjectBlocked { + reason: CrossProjectReason::OriginScopeMismatch { + origin: "admin-uuid".to_string(), + active: "demo-uuid".to_string(), + }, + guard_layer: GuardLayer::Fr2Worker, + }; + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains("cross-project"), + "missing cross-project label: {msg}" + ); + assert!( + msg.to_lowercase().contains("blocked"), + "missing 'blocked': {msg}" + ); + assert!( + msg.contains("origin_scope_mismatch"), + "missing reason as_str(): {msg}" + ); + assert!( + msg.contains("fr2_worker"), + "missing guard layer as_str(): {msg}" + ); + + let err = AppError::CrossProjectBlocked { + reason: CrossProjectReason::FormSelectionMismatch { + selected: "demo-uuid".to_string(), + active: "admin-uuid".to_string(), + }, + guard_layer: GuardLayer::Fr4Form, + }; + let msg = err.to_string(); + assert!( + msg.contains("form_selection_mismatch"), + "form mismatch reason missing: {msg}" + ); + assert!(msg.contains("fr4_form"), "fr4 layer missing: {msg}"); + } #[test] fn test_app_error_display() { diff --git a/src/event.rs b/src/event.rs index c8cd738..a1f5e89 100644 --- a/src/event.rs +++ b/src/event.rs @@ -190,6 +190,18 @@ pub enum AppEvent { operation: String, }, + // FR2 Cross-project block (BL-P2-085 Step 11c) + /// Worker rejected a stamped mutation because its `origin_project_id` + /// disagreed with the live active scope on `RbacGuard`. `reason` is the + /// stable `CrossProjectReason::as_str()` slug (`"origin_scope_mismatch"`, + /// `"unscoped_fail_safe"`, …); `action` is the `Action` variant name. + /// The structured audit entry is written by the worker via + /// `cross_project_audit::emit` before this event reaches the UI. + CrossProjectBlocked { + reason: String, + action: String, + }, + // System CloudSwitched(String), diff --git a/src/infra/cross_project_audit.rs b/src/infra/cross_project_audit.rs new file mode 100644 index 0000000..42837cd --- /dev/null +++ b/src/infra/cross_project_audit.rs @@ -0,0 +1,368 @@ +//! Cross-project block event + AuditLogger integration. +//! +//! Reuses the production `AuditLogger` (see `src/infra/audit.rs`) via +//! `to_audit_entry()` — this BL avoids introducing a parallel audit subsystem. +//! Specialized fields (`fingerprint`, `guard_layer`, `correlation_id`, +//! `asserted_origin_project_id`, `target_project_id`) are packed into the +//! `details` JSON. The `result` field uses `AuditResult::Failed("cross_project_block:")` +//! so analysts can grep by the stable reason as_str. +//! +//! Fingerprint v1 canonical format (LOCKED — bump to v2 on any change): +//! "v1|" + actor_user_id + "|" + active + "|" + origin + "|" + target +//! + "|" + action_type + "|" + resource_id +//! → sha256 → first 6 bytes → 12 lowercase hex chars. + +use std::fmt::Write as _; + +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; + +use crate::infra::audit::{AuditEntry, AuditLogger, AuditResult}; +use crate::infra::cross_project_guard::{CrossProjectReason, GuardLayer}; + +#[derive(Debug, Clone)] +pub struct CrossProjectBlockEvent { + pub timestamp: DateTime, + pub actor_user_id: String, + pub actor_cloud: String, + pub active_project_id: Option, + pub asserted_origin_project_id: Option, + pub target_project_id: Option, + pub action_type: String, + pub resource_kind: String, + pub resource_id: Option, + pub resource_name: Option, + pub reason: CrossProjectReason, + pub guard_layer: GuardLayer, + pub correlation_id: u64, +} + +impl CrossProjectBlockEvent { + /// Convenience constructor (BL-P2-085 Step 11b). Stamps `timestamp = Utc::now()`, + /// fills required fields from the worker's view of the dispatched action, + /// and leaves resource-bound optional fields (`target_project_id`, + /// `resource_id`, `resource_name`) as `None`. Callers with a resource-bound + /// action can mutate them directly after construction. + /// + /// `resource_kind` is a free-form string (e.g. `"server"`, `"volume"`); the + /// worker enriches it at the call site so this struct stays decoupled from + /// the `Action` enum. + #[allow(clippy::too_many_arguments)] + pub fn new( + reason: CrossProjectReason, + guard_layer: GuardLayer, + action_type: impl Into, + resource_kind: impl Into, + actor_cloud: impl Into, + actor_user_id: impl Into, + active_project_id: Option, + asserted_origin_project_id: Option, + correlation_id: u64, + ) -> Self { + Self { + timestamp: Utc::now(), + actor_user_id: actor_user_id.into(), + actor_cloud: actor_cloud.into(), + active_project_id, + asserted_origin_project_id, + target_project_id: None, + action_type: action_type.into(), + resource_kind: resource_kind.into(), + resource_id: None, + resource_name: None, + reason, + guard_layer, + correlation_id, + } + } + + /// v1 canonical fingerprint. Schema-stable: any change must bump to v2 + /// and migrate downstream audit analysts. + pub fn fingerprint(&self) -> String { + let canonical = format!( + "v1|{user}|{active}|{origin}|{target}|{action}|{resource}", + user = self.actor_user_id, + active = self.active_project_id.as_deref().unwrap_or(""), + origin = self.asserted_origin_project_id.as_deref().unwrap_or(""), + target = self.target_project_id.as_deref().unwrap_or(""), + action = self.action_type, + resource = self.resource_id.as_deref().unwrap_or(""), + ); + let digest = Sha256::digest(canonical.as_bytes()); + let mut hex = String::with_capacity(12); + for b in &digest[..6] { + // write! to a String never errors, but unwrap is denied; fall back to format! + let _ = write!(&mut hex, "{b:02x}"); + } + hex + } + + /// Map this event onto the production `AuditEntry` shape so it can be + /// written via the existing `AuditLogger` (rotation + sensitive masking). + pub fn to_audit_entry(&self) -> AuditEntry { + AuditEntry { + timestamp: self.timestamp.to_rfc3339(), + cloud: self.actor_cloud.clone(), + user: self.actor_user_id.clone(), + project: self.active_project_id.clone(), + action: self.action_type.clone(), + resource_type: self.resource_kind.clone(), + resource_id: self.resource_id.clone().unwrap_or_default(), + resource_name: self.resource_name.clone(), + details: Some(serde_json::json!({ + "fingerprint": self.fingerprint(), + "guard_layer": self.guard_layer.as_str(), + "correlation_id": self.correlation_id, + "asserted_origin_project_id": self.asserted_origin_project_id, + "target_project_id": self.target_project_id, + })), + result: AuditResult::Failed(format!("cross_project_block:{}", self.reason.as_str())), + } + } +} + +/// Best-effort emit. If `logger` is provided and `log_entry` succeeds, the +/// event is persisted to the audit log; otherwise the event is recorded via +/// `tracing::warn!` so it still surfaces in process logs. Never panics. +/// +/// Rotation parity: matches `App::record_audit` (src/app.rs:780) by invoking +/// `rotate_if_needed` after a successful write so cross-project events stay +/// within `MAX_LOG_SIZE`/`MAX_ROTATED_FILES` even under sustained block storms. +pub fn emit(event: &CrossProjectBlockEvent, logger: Option<&AuditLogger>) { + if let Some(logger) = logger { + match logger.log_entry(event.to_audit_entry()) { + Ok(()) => { + if let Err(e) = logger.rotate_if_needed() { + tracing::warn!( + error = %e, + fingerprint = %event.fingerprint(), + "cross_project_block: AuditLogger.rotate_if_needed failed", + ); + } + return; + } + Err(e) => { + tracing::warn!( + error = %e, + fingerprint = %event.fingerprint(), + reason = event.reason.as_str(), + guard_layer = event.guard_layer.as_str(), + "cross_project_block: AuditLogger.log_entry failed, falling back to tracing", + ); + } + } + } + tracing::warn!( + fingerprint = %event.fingerprint(), + reason = event.reason.as_str(), + guard_layer = event.guard_layer.as_str(), + correlation_id = event.correlation_id, + actor_user = %event.actor_user_id, + actor_cloud = %event.actor_cloud, + action = %event.action_type, + resource_kind = %event.resource_kind, + "cross_project_block", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use sha2::{Digest, Sha256}; + + use crate::infra::audit::{AuditLogger, AuditResult}; + use crate::infra::cross_project_guard::{CrossProjectReason, GuardLayer}; + + fn sample_event() -> CrossProjectBlockEvent { + CrossProjectBlockEvent { + timestamp: chrono::Utc.with_ymd_and_hms(2026, 4, 27, 12, 0, 0).unwrap(), + actor_user_id: "user-1".to_string(), + actor_cloud: "devstack".to_string(), + active_project_id: Some("active-1".to_string()), + asserted_origin_project_id: Some("origin-2".to_string()), + target_project_id: Some("target-3".to_string()), + action_type: "CreateServer".to_string(), + resource_kind: "server".to_string(), + resource_id: Some("res-99".to_string()), + resource_name: Some("web-01".to_string()), + reason: CrossProjectReason::OriginScopeMismatch { + origin: "origin-2".to_string(), + active: "active-1".to_string(), + }, + guard_layer: GuardLayer::Fr2Worker, + correlation_id: 42, + } + } + + fn hex12(bytes: &[u8]) -> String { + let mut s = String::with_capacity(12); + for b in &bytes[..6] { + s.push_str(&format!("{b:02x}")); + } + s + } + + #[test] + fn test_event_to_audit_entry_field_mapping() { + let event = sample_event(); + let entry = event.to_audit_entry(); + + assert_eq!(entry.timestamp, "2026-04-27T12:00:00+00:00"); + assert_eq!(entry.cloud, "devstack"); + assert_eq!(entry.user, "user-1"); + assert_eq!(entry.project.as_deref(), Some("active-1")); + assert_eq!(entry.action, "CreateServer"); + assert_eq!(entry.resource_type, "server"); + assert_eq!(entry.resource_id, "res-99"); + assert_eq!(entry.resource_name.as_deref(), Some("web-01")); + assert!(entry.details.is_some(), "details must be packed"); + } + + #[test] + fn test_audit_entry_details_contains_fingerprint_guard_layer_correlation_id() { + let event = sample_event(); + let entry = event.to_audit_entry(); + let details = entry.details.expect("details present"); + + assert!(details.get("fingerprint").is_some(), "fingerprint missing"); + assert_eq!(details["guard_layer"], "fr2_worker"); + assert_eq!(details["correlation_id"], 42); + assert_eq!(details["asserted_origin_project_id"], "origin-2"); + assert_eq!(details["target_project_id"], "target-3"); + } + + #[test] + fn test_audit_entry_result_is_failed_with_reason_string() { + let event = sample_event(); + let entry = event.to_audit_entry(); + match entry.result { + AuditResult::Failed(s) => { + assert_eq!(s, "cross_project_block:origin_scope_mismatch"); + } + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn test_fingerprint_v1_canonical_format() { + let event = sample_event(); + let fp = event.fingerprint(); + + // length + lowercase hex contract + assert_eq!(fp.len(), 12, "fingerprint must be 12 hex chars"); + assert!( + fp.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "fingerprint must be lowercase hex: {fp}" + ); + + // canonical formula re-derived independently of impl + let canonical = "v1|user-1|active-1|origin-2|target-3|CreateServer|res-99"; + let mut h = Sha256::new(); + h.update(canonical.as_bytes()); + let expected = hex12(&h.finalize()); + assert_eq!( + fp, expected, + "fingerprint canonical changed — bump v1→v2 + migrate analysts" + ); + } + + #[test] + fn test_fingerprint_boundary_collision_free() { + let mut e1 = sample_event(); + e1.actor_user_id = "ab".to_string(); + e1.active_project_id = Some(String::new()); + let mut e2 = sample_event(); + e2.actor_user_id = "a".to_string(); + e2.active_project_id = Some("b".to_string()); + + assert_ne!( + e1.fingerprint(), + e2.fingerprint(), + "delimiter must prevent ('ab','') vs ('a','b') collision" + ); + } + + #[test] + fn test_fingerprint_none_resource_id_uses_empty() { + let mut e_none = sample_event(); + e_none.resource_id = None; + let mut e_empty = sample_event(); + e_empty.resource_id = Some(String::new()); + + assert_eq!( + e_none.fingerprint(), + e_empty.fingerprint(), + "None resource_id and empty string must hash identically" + ); + } + + #[test] + fn test_emit_with_logger_writes_audit_entry() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("audit.log"); + let logger = AuditLogger::new(path.clone()).unwrap(); + + let event = sample_event(); + emit(&event, Some(&logger)); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(!content.is_empty(), "audit log must contain entry"); + let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(parsed["action"], "CreateServer"); + assert_eq!( + parsed["result"], + serde_json::json!({ "failed": "cross_project_block:origin_scope_mismatch" }) + ); + assert_eq!(parsed["details"]["guard_layer"], "fr2_worker"); + assert_eq!(parsed["details"]["correlation_id"], 42); + } + + #[test] + fn test_emit_without_logger_fallback_to_tracing() { + let event = sample_event(); + // Must not panic. Tracing subscriber is not asserted (no tracing-test dep). + emit(&event, None); + } + + // --- BL-P2-085 Step 11b: convenience constructor --- + + #[test] + fn test_new_convenience_constructor_fills_required_fields_and_now_timestamp() { + let before = Utc::now(); + let event = CrossProjectBlockEvent::new( + CrossProjectReason::OriginScopeMismatch { + origin: "p-stale".into(), + active: "p-active".into(), + }, + GuardLayer::Fr2Worker, + "DeleteServer", + "server", + "devstack", + "user-uuid", + Some("p-active".into()), + Some("p-stale".into()), + 7, + ); + let after = Utc::now(); + + assert_eq!(event.actor_cloud, "devstack"); + assert_eq!(event.actor_user_id, "user-uuid"); + assert_eq!(event.action_type, "DeleteServer"); + assert_eq!(event.resource_kind, "server"); + assert_eq!(event.active_project_id.as_deref(), Some("p-active")); + assert_eq!(event.asserted_origin_project_id.as_deref(), Some("p-stale")); + assert_eq!(event.guard_layer, GuardLayer::Fr2Worker); + assert_eq!(event.correlation_id, 7); + // Optional fields default to None — caller can override after construction. + assert!(event.target_project_id.is_none()); + assert!(event.resource_id.is_none()); + assert!(event.resource_name.is_none()); + // Timestamp is "now" — within the call window. + assert!( + event.timestamp >= before && event.timestamp <= after, + "timestamp must reflect Utc::now() at construction" + ); + } +} diff --git a/src/infra/cross_project_guard.rs b/src/infra/cross_project_guard.rs new file mode 100644 index 0000000..d6381b2 --- /dev/null +++ b/src/infra/cross_project_guard.rs @@ -0,0 +1,218 @@ +//! Cross-project scoping guard — pure-function decision module. +//! +//! Used by FR2 (worker mutation guard), FR3 (RBAC project-scope check), +//! and FR4 (form selection validator). Returns structured `GuardDecision`s +//! that callers translate into [`AppError::CrossProjectBlocked`] and +//! [`CrossProjectBlockEvent`]. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardDecision { + Allow, + Block { reason: CrossProjectReason }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CrossProjectReason { + /// FR2: action stamp 시점의 origin scope ≠ 현재 active scope + OriginScopeMismatch { origin: String, active: String }, + /// FR4: form-selected resource's project_id ≠ active scope + FormSelectionMismatch { selected: String, active: String }, + /// FR1: adapter response에 cross-project resource 잠입 (불변 위반) + AdapterFilterViolation { + resource_id: String, + project_id: String, + }, + /// scope가 unscoped/None이라 비교 불가능. fail-safe deny. + UnscopedFailSafe, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuardLayer { + Fr1Adapter, + Fr2Worker, + Fr3Rbac, + Fr4Form, +} + +impl GuardLayer { + /// Stable string for audit `details.guard_layer` field. + pub fn as_str(self) -> &'static str { + match self { + GuardLayer::Fr1Adapter => "fr1_adapter", + GuardLayer::Fr2Worker => "fr2_worker", + GuardLayer::Fr3Rbac => "fr3_rbac", + GuardLayer::Fr4Form => "fr4_form", + } + } +} + +impl CrossProjectReason { + /// Stable string for audit `details.reason` field. Schema-stable. + pub fn as_str(&self) -> &'static str { + match self { + CrossProjectReason::OriginScopeMismatch { .. } => "origin_scope_mismatch", + CrossProjectReason::FormSelectionMismatch { .. } => "form_selection_mismatch", + CrossProjectReason::AdapterFilterViolation { .. } => "adapter_filter_violation", + CrossProjectReason::UnscopedFailSafe => "unscoped_fail_safe", + } + } +} + +/// FR2 worker hook가 호출. action 발행 시점의 origin과 현재 active scope 비교. +pub fn check_origin_scope(origin: &str, active: &str) -> GuardDecision { + if origin.is_empty() || active.is_empty() { + return GuardDecision::Block { + reason: CrossProjectReason::UnscopedFailSafe, + }; + } + if origin == active { + GuardDecision::Allow + } else { + GuardDecision::Block { + reason: CrossProjectReason::OriginScopeMismatch { + origin: origin.to_string(), + active: active.to_string(), + }, + } + } +} + +/// FR4 form validator가 호출. selected resource의 project_id와 active scope 비교. +pub fn check_form_selection(selected_project_id: &str, active: &str) -> GuardDecision { + if active.is_empty() { + return GuardDecision::Block { + reason: CrossProjectReason::UnscopedFailSafe, + }; + } + if selected_project_id.is_empty() { + // owner 정보가 없는 리소스 — fail-safe deny (Glance Image.owner=None 등) + return GuardDecision::Block { + reason: CrossProjectReason::UnscopedFailSafe, + }; + } + if selected_project_id == active { + GuardDecision::Allow + } else { + GuardDecision::Block { + reason: CrossProjectReason::FormSelectionMismatch { + selected: selected_project_id.to_string(), + active: active.to_string(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_check_origin_scope_match_allows() { + assert_eq!( + check_origin_scope("admin-uuid", "admin-uuid"), + GuardDecision::Allow + ); + } + + #[test] + fn test_check_origin_scope_mismatch_blocks() { + let decision = check_origin_scope("admin-uuid", "demo-uuid"); + match decision { + GuardDecision::Block { + reason: CrossProjectReason::OriginScopeMismatch { origin, active }, + } => { + assert_eq!(origin, "admin-uuid"); + assert_eq!(active, "demo-uuid"); + } + other => panic!("expected origin mismatch, got {other:?}"), + } + } + + #[test] + fn test_check_origin_scope_empty_origin_fail_safe() { + let decision = check_origin_scope("", "demo"); + assert!(matches!( + decision, + GuardDecision::Block { + reason: CrossProjectReason::UnscopedFailSafe + } + )); + } + + #[test] + fn test_check_origin_scope_empty_active_fail_safe() { + let decision = check_origin_scope("admin", ""); + assert!(matches!( + decision, + GuardDecision::Block { + reason: CrossProjectReason::UnscopedFailSafe + } + )); + } + + #[test] + fn test_check_form_selection_match_allows() { + assert_eq!( + check_form_selection("admin-uuid", "admin-uuid"), + GuardDecision::Allow + ); + } + + #[test] + fn test_check_form_selection_mismatch_blocks() { + let decision = check_form_selection("demo-uuid", "admin-uuid"); + match decision { + GuardDecision::Block { + reason: CrossProjectReason::FormSelectionMismatch { selected, active }, + } => { + assert_eq!(selected, "demo-uuid"); + assert_eq!(active, "admin-uuid"); + } + other => panic!("expected form selection mismatch, got {other:?}"), + } + } + + #[test] + fn test_check_form_selection_empty_selected_fail_safe() { + // Glance Image.owner=None 시나리오 + let decision = check_form_selection("", "admin-uuid"); + assert!(matches!( + decision, + GuardDecision::Block { + reason: CrossProjectReason::UnscopedFailSafe + } + )); + } + + #[test] + fn test_guard_layer_as_str_stable() { + assert_eq!(GuardLayer::Fr1Adapter.as_str(), "fr1_adapter"); + assert_eq!(GuardLayer::Fr2Worker.as_str(), "fr2_worker"); + assert_eq!(GuardLayer::Fr3Rbac.as_str(), "fr3_rbac"); + assert_eq!(GuardLayer::Fr4Form.as_str(), "fr4_form"); + } + + #[test] + fn test_reason_as_str_stable() { + let r = CrossProjectReason::OriginScopeMismatch { + origin: "a".into(), + active: "b".into(), + }; + assert_eq!(r.as_str(), "origin_scope_mismatch"); + + let r = CrossProjectReason::FormSelectionMismatch { + selected: "x".into(), + active: "y".into(), + }; + assert_eq!(r.as_str(), "form_selection_mismatch"); + + let r = CrossProjectReason::AdapterFilterViolation { + resource_id: "rid".into(), + project_id: "pid".into(), + }; + assert_eq!(r.as_str(), "adapter_filter_violation"); + + let r = CrossProjectReason::UnscopedFailSafe; + assert_eq!(r.as_str(), "unscoped_fail_safe"); + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 62826ee..ae49b4c 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,6 +1,8 @@ pub mod audit; pub mod cache; pub mod catalog; +pub mod cross_project_audit; +pub mod cross_project_guard; pub mod cross_tenant; pub mod rbac; pub mod transition_guard; diff --git a/src/infra/rbac.rs b/src/infra/rbac.rs index cd9c974..a318f8a 100644 --- a/src/infra/rbac.rs +++ b/src/infra/rbac.rs @@ -30,6 +30,36 @@ pub enum EffectiveRole { Admin, } +/// Combined RBAC decision over (role-tier × project-scope). +/// Returned by `RbacGuard::check_project_scope` (FR3 of BL-P2-085). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RbacScopeDecision { + Allow, + Deny { reason: RbacDenialReason }, +} + +/// Why an RBAC scope check denied an action. Stable strings via `as_str()` +/// so audit consumers can grep. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RbacDenialReason { + /// `can_perform(action)` returned false (insufficient privilege tier). + RoleTier, + /// Active scope ≠ target_project_id, or scope is unscoped (fail-safe). + ProjectScope, + /// Both role-tier and project-scope failed. + Both, +} + +impl RbacDenialReason { + pub fn as_str(self) -> &'static str { + match self { + RbacDenialReason::RoleTier => "role_tier", + RbacDenialReason::ProjectScope => "project_scope", + RbacDenialReason::Both => "both", + } + } +} + impl EffectiveRole { /// Derive the highest-privilege role from a list of Keystone roles. /// Unknown roles are ignored. If no known role matches, defaults to Reader. @@ -58,6 +88,36 @@ struct RbacState { capabilities: HashSet, } +impl RbacState { + /// Snapshot-based scope decision. Operates purely on `&self` field reads + /// so that callers holding a single `RwLock` read guard get an atomic + /// (role × scope) decision — preventing the BL-P2-085 Codex P1 race + /// where role and project_id could be sampled from different snapshots. + fn scope_decision(&self, target_project_id: &str, action: ActionKind) -> RbacScopeDecision { + let role_ok = match self.effective_role { + EffectiveRole::Admin => true, + EffectiveRole::Member => !RbacGuard::is_admin_only_action(action), + EffectiveRole::Reader => action == ActionKind::Read, + }; + let scope_ok = self + .project_id + .as_deref() + .is_some_and(|p| p == target_project_id); + match (role_ok, scope_ok) { + (true, true) => RbacScopeDecision::Allow, + (false, true) => RbacScopeDecision::Deny { + reason: RbacDenialReason::RoleTier, + }, + (true, false) => RbacScopeDecision::Deny { + reason: RbacDenialReason::ProjectScope, + }, + (false, false) => RbacScopeDecision::Deny { + reason: RbacDenialReason::Both, + }, + } + } +} + /// Role-based access control guard. /// Phase 1 (current): 3-tier role-based filtering (Admin/Member/Reader). /// Phase 2: Capability-based extension via `has_capability()` / `update_capabilities()`. @@ -90,6 +150,21 @@ impl RbacGuard { } } + /// Refresh roles and re-derive `effective_role` without touching + /// `project_id`. Used on token re-issue paths where the active project + /// has not changed (e.g. Keystone token refresh inside the same scope). + /// + /// Mirrors `update_roles` by clearing capabilities so callers must + /// repopulate them via `update_capabilities` if needed. + pub fn update_roles_preserve_project(&self, roles: Vec) { + let effective = EffectiveRole::from_roles(&roles); + if let Ok(mut s) = self.state.write() { + s.roles = roles; + s.effective_role = effective; + s.capabilities.clear(); + } + } + /// Update capabilities from AuthProvider. /// Phase 1: derives all capabilities for admin. /// Phase 2: populated from backend-specific capabilities. @@ -133,6 +208,30 @@ impl RbacGuard { } } + /// Combined RBAC check: role-tier (`can_perform`) + project-scope + /// (`target_project_id == active project_id`). Returns a structured + /// reason so audit consumers and toasts can disambiguate role-tier vs + /// scope-mismatch denials. Unscoped guard (`project_id == None`) is + /// treated as a scope-mismatch fail-safe. + /// + /// Atomicity (Codex P1, 2026-04-28): role and project_id are read from a + /// single `state.read()` snapshot via `RbacState::scope_decision`, so a + /// concurrent `update_roles*` cannot interleave between the two reads. + /// On a poisoned lock the decision falls back to `Deny { Both }` + /// (fail-safe — no privilege should be granted on corrupt state). + pub fn check_project_scope( + &self, + target_project_id: &str, + action: ActionKind, + ) -> RbacScopeDecision { + self.state + .read() + .map(|s| s.scope_decision(target_project_id, action)) + .unwrap_or(RbacScopeDecision::Deny { + reason: RbacDenialReason::Both, + }) + } + /// Capability-based permission check. /// If capabilities are populated, checks against them. /// Otherwise falls back to role-based check: Admin = all, Member/Reader = denied. @@ -523,4 +622,202 @@ mod tests { guard.update_roles(vec![role("member")], None); assert!(!guard.has_capability("server", "delete")); } + + // --- BL-P2-085 Step 5: check_project_scope (FR3 RBAC project-scope) --- + + #[test] + fn test_check_project_scope_admin_match_allows() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + assert_eq!( + guard.check_project_scope("proj-A", ActionKind::Create), + RbacScopeDecision::Allow + ); + } + + #[test] + fn test_check_project_scope_admin_mismatch_denies_project_scope() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + assert_eq!( + guard.check_project_scope("proj-B", ActionKind::Delete), + RbacScopeDecision::Deny { + reason: RbacDenialReason::ProjectScope + } + ); + } + + #[test] + fn test_check_project_scope_unscoped_denies_project_scope_fail_safe() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("admin")], None); // unscoped admin + assert_eq!( + guard.check_project_scope("proj-A", ActionKind::Create), + RbacScopeDecision::Deny { + reason: RbacDenialReason::ProjectScope + }, + "None scope must fail-safe deny on the scope dimension" + ); + } + + #[test] + fn test_check_project_scope_reader_create_match_denies_role_tier() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("reader")], Some("proj-A".into())); + assert_eq!( + guard.check_project_scope("proj-A", ActionKind::Create), + RbacScopeDecision::Deny { + reason: RbacDenialReason::RoleTier + } + ); + } + + #[test] + fn test_check_project_scope_reader_create_mismatch_denies_both() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("reader")], Some("proj-A".into())); + assert_eq!( + guard.check_project_scope("proj-B", ActionKind::Create), + RbacScopeDecision::Deny { + reason: RbacDenialReason::Both + } + ); + } + + #[test] + fn test_check_project_scope_member_admin_only_action_match_denies_role_tier() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("member")], Some("proj-A".into())); + // ForceDelete is admin-only; scope matches + assert_eq!( + guard.check_project_scope("proj-A", ActionKind::ForceDelete), + RbacScopeDecision::Deny { + reason: RbacDenialReason::RoleTier + } + ); + } + + #[test] + fn test_check_project_scope_reader_read_match_allows() { + // Reader can Read in own scope + let guard = RbacGuard::new(); + guard.update_roles(vec![role("reader")], Some("proj-A".into())); + assert_eq!( + guard.check_project_scope("proj-A", ActionKind::Read), + RbacScopeDecision::Allow + ); + } + + // --- BL-P2-085 Step 6: update_roles_preserve_project (token re-issue path) --- + + #[test] + fn test_preserve_project_keeps_existing_project_id() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + guard.update_roles_preserve_project(vec![role("member")]); + assert_eq!( + guard.project_id(), + Some("proj-A".to_string()), + "preserve must not touch project_id" + ); + } + + #[test] + fn test_preserve_project_updates_roles_and_effective() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + assert_eq!(guard.effective_role(), EffectiveRole::Admin); + + guard.update_roles_preserve_project(vec![role("reader")]); + assert_eq!(guard.effective_role(), EffectiveRole::Reader); + assert!(!guard.is_admin()); + } + + #[test] + fn test_update_roles_vs_preserve_diff() { + // Regression: original update_roles still overwrites project_id. + let guard1 = RbacGuard::new(); + guard1.update_roles(vec![role("admin")], Some("proj-A".into())); + guard1.update_roles(vec![role("member")], Some("proj-B".into())); + assert_eq!(guard1.project_id(), Some("proj-B".to_string())); + + let guard2 = RbacGuard::new(); + guard2.update_roles(vec![role("admin")], Some("proj-A".into())); + guard2.update_roles_preserve_project(vec![role("member")]); + assert_eq!(guard2.project_id(), Some("proj-A".to_string())); + } + + #[test] + fn test_preserve_project_clears_capabilities_parity_with_update_roles() { + let guard = RbacGuard::new(); + guard.update_roles(vec![role("admin")], Some("proj-A".into())); + guard.update_capabilities(vec![Capability { + resource: "server".to_string(), + action: "delete".to_string(), + }]); + assert!(guard.has_capability("server", "delete")); + + guard.update_roles_preserve_project(vec![role("member")]); + assert!( + !guard.has_capability("server", "delete"), + "preserve must clear capabilities for parity with update_roles" + ); + } + + // --- BL-P2-085 Codex P1: atomic scope decision over single state snapshot --- + + #[test] + fn test_rbac_state_scope_decision_atomic_snapshot() { + // Codex P1 fix: check_project_scope must read role + project_id from + // a single RwLock snapshot. This test exercises RbacState::scope_decision + // directly (no lock involved) to lock in the snapshot-based contract: + // any future race-fix refactor must keep the decision derivable from + // a single &RbacState reference. + let state = RbacState { + roles: vec![role("admin")], + project_id: Some("proj-a".into()), + effective_role: EffectiveRole::Admin, + capabilities: HashSet::new(), + }; + assert_eq!( + state.scope_decision("proj-a", ActionKind::Create), + RbacScopeDecision::Allow, + "admin in matching scope must Allow" + ); + assert_eq!( + state.scope_decision("proj-b", ActionKind::Create), + RbacScopeDecision::Deny { + reason: RbacDenialReason::ProjectScope + }, + "admin in mismatched scope must Deny ProjectScope" + ); + + let unscoped = RbacState { + roles: vec![role("admin")], + project_id: None, + effective_role: EffectiveRole::Admin, + capabilities: HashSet::new(), + }; + assert_eq!( + unscoped.scope_decision("proj-a", ActionKind::Create), + RbacScopeDecision::Deny { + reason: RbacDenialReason::ProjectScope + }, + "unscoped guard must fail-safe to ProjectScope denial" + ); + + let reader = RbacState { + roles: vec![role("reader")], + project_id: Some("proj-a".into()), + effective_role: EffectiveRole::Reader, + capabilities: HashSet::new(), + }; + assert_eq!( + reader.scope_decision("proj-b", ActionKind::Create), + RbacScopeDecision::Deny { + reason: RbacDenialReason::Both + }, + "role-tier + scope mismatch must Deny Both" + ); + } } diff --git a/src/main.rs b/src/main.rs index 17bf3ca..9f0fd3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,9 +18,11 @@ use nexttui::adapter::auth::scoped_session::ScopedAuthSession; use nexttui::adapter::auth::token_cache::{self as token_cache, TokenCacheStore}; use nexttui::adapter::auth::{DirectoryCache, DomainNameResolver, KeystoneProjectDirectory}; use nexttui::adapter::http::endpoint_invalidator::EndpointCatalogInvalidator; +use nexttui::adapter::http::neutron_audit::build_audit_config; use nexttui::adapter::registry::AdapterRegistry; use nexttui::app::App; use nexttui::config::Config; +use nexttui::context::action_channel::ScopeProvider; use nexttui::context::{ CancellationRegistry, ConfigCloudDirectory, ContextHistoryStore, ContextSwitcher, ContextTargetResolver, SwitchStateMachine, @@ -91,9 +93,16 @@ async fn main() -> Result<(), Box> { tracing::warn!(warning = %w, "config warning"); } + // RbacGuard is created up-front so the same Arc can be shared between + // ActionSender (FR2 origin stamping, BL-P2-085 Step 9) and the App / + // worker downstream. The token-derived role/project_id update + // happens later (after auth_provider returns the token). + let rbac = std::sync::Arc::new(nexttui::infra::rbac::RbacGuard::new()); + let current_epoch = Arc::new(nexttui::context::ContextEpoch::new()); let (action_raw_tx, action_rx) = mpsc::unbounded_channel(); - let action_tx = nexttui::context::ActionSender::new(action_raw_tx, current_epoch.clone()); + let action_tx = + nexttui::context::ActionSender::new(action_raw_tx, current_epoch.clone(), rbac.clone()); let (event_tx, event_rx) = mpsc::unbounded_channel(); // Build auth credential from config @@ -131,17 +140,18 @@ async fn main() -> Result<(), Box> { AuthMethod::ApplicationCredential { id, .. } => id.clone(), }; - let auth_provider = Arc::new(KeystoneAuthAdapter::new(credential)?); - let registry = Arc::new(AdapterRegistry::new_http( - auth_provider.clone(), - cloud.region_name.clone(), - )?); + // Capture owned copies so the `cloud` borrow can be released before + // `config` is moved into `App::from_registry`. Step 13b-3 deferred + // the `AdapterRegistry::new_http` call until after the App is built, + // and the registry constructor still needs `region_name`. + let cloud_region = cloud.region_name.clone(); - // === Phase B: collect endpoint caches before worker consumes registry === - let endpoint_caches = registry.endpoint_caches().to_vec(); + let auth_provider = Arc::new(KeystoneAuthAdapter::new(credential)?); // Trigger initial authentication, then initialize RBAC from token roles - let rbac = std::sync::Arc::new(nexttui::infra::rbac::RbacGuard::new()); + // (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). let _ = auth_provider.get_token().await; // force auth before reading roles if let Ok(token) = auth_provider.get_token_info().await { rbac.update_roles(token.roles, Some(token.project.id)); @@ -151,13 +161,55 @@ async fn main() -> Result<(), Box> { let (mut app, initial_actions) = App::from_registry(config, action_tx.clone(), module_registry, rbac.clone()); - // Spawn background worker + // BL-P2-085 Phase 7: share the same `Arc` with `App` so + // 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`. + let audit_logger = app.audit_logger_arc(); + let initial_user_id = if wire_username.is_empty() { + "unknown".to_string() + } else { + wire_username.clone() + }; + let actor_ctx = Arc::new(std::sync::RwLock::new(nexttui::worker::ActorContext { + cloud: config_for_wire.active_cloud_name().to_string(), + user_id: initial_user_id, + })); + app.set_actor_ctx(actor_ctx.clone()); + + // BL-P2-085 Step-14-precedent-refactor-3: bundle per-service audit + // contexts via `build_audit_config` once `audit_logger`, `rbac` + // (live ScopeProvider), and `actor_ctx` are all available. Today + // only `audit.neutron` is consumed by the registry; Step 14 wires + // `audit.nova` / `audit.cinder` once those adapters gain + // `with_audit`. + let audit_config = build_audit_config( + audit_logger.clone(), + rbac.clone() as Arc, + actor_ctx.clone(), + ); + + let registry = Arc::new(AdapterRegistry::new_http( + auth_provider.clone(), + cloud_region, + audit_config, + )?); + + // === Phase B: collect endpoint caches before worker consumes registry === + let endpoint_caches = registry.endpoint_caches().to_vec(); + tokio::spawn(run_worker( registry, rbac, app.all_tenants.clone(), action_rx, event_tx.clone(), + audit_logger, + actor_ctx, )); // Trigger initial data load diff --git a/src/module/common/mod.rs b/src/module/common/mod.rs new file mode 100644 index 0000000..dfac7ea --- /dev/null +++ b/src/module/common/mod.rs @@ -0,0 +1,4 @@ +//! Cross-cutting helpers shared by domain modules. BL-P2-085 Step 15 +//! introduces `scope_validator` for FR4 form-selection scope checking. + +pub mod scope_validator; diff --git a/src/module/common/scope_validator.rs b/src/module/common/scope_validator.rs new file mode 100644 index 0000000..7d48c37 --- /dev/null +++ b/src/module/common/scope_validator.rs @@ -0,0 +1,168 @@ +//! BL-P2-085 Step 15 — Form selection scope validator (FR4). +//! +//! Pure-function helper used by domain modules to reject form submits +//! whose selected resource(s) live in a project other than the current +//! active scope. Returns the first mismatch — callers translate it to a +//! `CrossProjectBlockEvent` + toast. +//! +//! Distinct from FR2 (worker origin guard) and FR1 (adapter refilter): +//! FR4 fires *before* dispatch, at the form-submit boundary, so the user +//! sees a clear "you selected a resource from another project" message +//! instead of a silent block. + +use crate::infra::cross_project_guard::CrossProjectReason; + +/// One row of form-selected scope-bearing input. `project_id == None` +/// means the selection has no scope label (e.g. UI didn't load it yet, +/// or the upstream omitted it); under FR4 such inputs are dropped to +/// the caller's fail-safe path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormSelection<'a> { + /// Logical field name as shown to the user (`"image"`, `"network"`, + /// `"flavor"`, etc.). Carried into the resulting error so the toast + /// can highlight which row mismatched. + pub field: &'a str, + /// Project id the selected resource belongs to. `None` = fail-safe + /// deny (treated as mismatch unless caller normalizes earlier). + pub project_id: Option<&'a str>, +} + +/// Error raised when a form selection's project_id disagrees with the +/// active scope. Carries the first offending field plus the canonical +/// `CrossProjectReason::FormSelectionMismatch` so the audit emit path +/// stays unified with FR1/FR2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormValidationError { + pub field: String, + pub reason: CrossProjectReason, +} + +/// Validate `selections` against `active`. Returns `Err` on the first +/// row whose `project_id != Some(active)`; `None` is treated as +/// mismatch (`selected = ""`) so missing-scope inputs fail safely. +/// +/// `active` is expected non-empty — an empty `active` would degrade to +/// `UnscopedFailSafe` semantics, which the worker-side guard already +/// covers. Callers should short-circuit to the unscoped path before +/// invoking this validator. +pub fn validate_form_scope( + active: &str, + selections: &[FormSelection<'_>], +) -> Result<(), FormValidationError> { + // Parity with `RefilterScope::strict` — an empty active would let + // selections with `project_id == Some("")` silent-allow. Caught in + // dev builds; production callers must short-circuit to the + // unscoped path (worker FR2) before reaching this validator. + debug_assert!( + !active.is_empty(), + "validate_form_scope requires a non-empty active project id — empty would silent-allow Some(\"\")" + ); + for sel in selections { + let matches = sel.project_id == Some(active); + if !matches { + let selected = sel.project_id.unwrap_or("").to_string(); + return Err(FormValidationError { + field: sel.field.to_string(), + reason: CrossProjectReason::FormSelectionMismatch { + selected, + active: active.to_string(), + }, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_single_selection_match_passes() { + let selections = [FormSelection { + field: "image", + project_id: Some("A"), + }]; + let result = validate_form_scope("A", &selections); + assert!(result.is_ok(), "matching project_id must pass"); + } + + #[test] + fn test_validate_single_selection_mismatch_returns_error() { + let selections = [FormSelection { + field: "image", + project_id: Some("B"), + }]; + let err = validate_form_scope("A", &selections).expect_err("mismatch must error"); + assert_eq!(err.field, "image"); + match err.reason { + CrossProjectReason::FormSelectionMismatch { selected, active } => { + assert_eq!(selected, "B"); + assert_eq!(active, "A"); + } + other => panic!("expected FormSelectionMismatch, got {other:?}"), + } + } + + #[test] + fn test_validate_multi_selection_first_mismatch_wins() { + let selections = [ + FormSelection { + field: "image", + project_id: Some("A"), + }, + FormSelection { + field: "network", + project_id: Some("B"), + }, + FormSelection { + field: "security_group", + project_id: Some("C"), + }, + ]; + let err = validate_form_scope("A", &selections).expect_err("must error on first mismatch"); + assert_eq!( + err.field, "network", + "first mismatched field must be reported, subsequent mismatches ignored" + ); + match err.reason { + CrossProjectReason::FormSelectionMismatch { selected, .. } => { + assert_eq!(selected, "B"); + } + other => panic!("expected FormSelectionMismatch, got {other:?}"), + } + } + + // --- cargo-review branch-full Correctness #3 (S-class follow-up) --- + // RefilterScope::strict has a debug_assert against empty active; the + // form validator must enforce the same invariant so an upstream that + // leaks `active == ""` doesn't silent-allow rows with + // `project_id == Some("")`. + + #[test] + #[should_panic(expected = "non-empty active")] + fn test_validate_form_scope_panics_on_empty_active() { + let selections = [FormSelection { + field: "image", + project_id: Some("A"), + }]; + let _ = validate_form_scope("", &selections); + } + + #[test] + fn test_validation_error_carries_field_name_and_reason() { + let selections = [FormSelection { + field: "flavor", + project_id: None, + }]; + let err = validate_form_scope("A", &selections).expect_err("None must fail-safe"); + assert_eq!(err.field, "flavor"); + match err.reason { + CrossProjectReason::FormSelectionMismatch { selected, active } => { + assert_eq!(selected, "", "None project_id encodes as empty selected"); + assert_eq!(active, "A"); + } + other => panic!("expected FormSelectionMismatch, got {other:?}"), + } + } +} diff --git a/src/module/mod.rs b/src/module/mod.rs index 3664f27..ec82ca1 100644 --- a/src/module/mod.rs +++ b/src/module/mod.rs @@ -1,5 +1,6 @@ pub mod agent; pub mod aggregate; +pub mod common; pub mod compute_service; pub mod flavor; pub mod floating_ip; diff --git a/src/port/types.rs b/src/port/types.rs index 5332f4f..ea115d3 100644 --- a/src/port/types.rs +++ b/src/port/types.rs @@ -232,16 +232,19 @@ pub struct ImageListFilter { #[derive(Debug, Clone, Default)] pub struct NetworkListFilter { pub all_tenants: bool, + pub tenant_id: Option, } #[derive(Debug, Clone, Default)] pub struct SecurityGroupListFilter { pub all_tenants: bool, + pub tenant_id: Option, } #[derive(Debug, Clone, Default)] pub struct FloatingIpListFilter { pub all_tenants: bool, + pub tenant_id: Option, } #[derive(Debug, Clone, Default)] diff --git a/src/ui/cross_project_toast.rs b/src/ui/cross_project_toast.rs new file mode 100644 index 0000000..8cd9c66 --- /dev/null +++ b/src/ui/cross_project_toast.rs @@ -0,0 +1,137 @@ +//! BL-P2-085 Step 17 (Phase 10) — User-facing toast builders for the +//! three cross-project block scenarios. +//! +//! Each builder returns `(message, ToastLevel::Error)` so the caller can +//! hand it straight to `BackgroundTracker::add_toast`. Error level is the +//! deliberate choice — the user just attempted a privileged action and +//! got blocked, so the toast must outlive the longer Error TTL (Phase 7 +//! Step 11c parity with `PermissionDenied`). +//! +//! Project / image / field names are truncated to a 60-character display +//! window so a malicious or pathological name can't overrun the +//! one-line toast region. This is a char-count truncate; a future cycle +//! tied to BL-P2-050 can swap in width-aware shortening via +//! `unicode-width` without touching call sites. +//! +//! Naming conventions mirror the audit `CrossProjectReason` shape: +//! - `origin_mismatch_toast` — FR2 worker block (dispatch origin ≠ +//! current active scope). +//! - `glance_owner_mismatch_toast` — FR4 pre-mutation block specifically +//! for Glance images (owner != active). +//! - `form_mismatch_toast` — FR4 generic form-selection block +//! (multi-field form, named field mismatch). + +use crate::background::ToastLevel; + +/// Char-count truncate used by all three builders. Returns the input +/// unchanged when it fits within `max_chars`; otherwise truncates and +/// suffixes with `…` (single char so the visible budget stays +/// predictable). Char-based for now — width-aware variant via +/// `unicode-width` will land with BL-P2-050. +fn safe_display_60(value: &str) -> String { + const MAX_CHARS: usize = 60; + let count = value.chars().count(); + if count <= MAX_CHARS { + return value.to_string(); + } + // Reserve 1 char for the ellipsis so the total visible width stays + // at MAX_CHARS exactly. + let kept: String = value.chars().take(MAX_CHARS - 1).collect(); + format!("{kept}…") +} + +/// FR2 worker-layer block toast (dispatch-time origin ≠ active scope). +/// +/// `origin` / `target` are project ids (or display names) carried in +/// `CrossProjectReason::OriginScopeMismatch`. Both are truncated. +pub fn origin_mismatch_toast(origin: &str, target: &str) -> (String, ToastLevel) { + let msg = format!( + "Cross-project block: action originated in '{}' but active scope is '{}'", + safe_display_60(origin), + safe_display_60(target), + ); + (msg, ToastLevel::Error) +} + +/// FR4 pre-mutation block toast for Glance images. The `owner` argument +/// is the image's project id from `Image.owner`; `active` is the user's +/// current scope. Both are truncated. +pub fn glance_owner_mismatch_toast(owner: &str, active: &str) -> (String, ToastLevel) { + let msg = format!( + "Image belongs to project '{}' but active scope is '{}' — refusing cross-project mutation", + safe_display_60(owner), + safe_display_60(active), + ); + (msg, ToastLevel::Error) +} + +/// FR4 generic form-selection block toast. `field` is the form field +/// label the user selected (e.g. `"network"`, `"flavor"`). All three +/// strings are truncated. +pub fn form_mismatch_toast(field: &str, selected: &str, active: &str) -> (String, ToastLevel) { + let msg = format!( + "Form field '{}' references project '{}' but active scope is '{}'", + safe_display_60(field), + safe_display_60(selected), + safe_display_60(active), + ); + (msg, ToastLevel::Error) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_origin_mismatch_toast_contains_both_project_names() { + let (msg, _) = origin_mismatch_toast("proj-A", "proj-B"); + assert!(msg.contains("proj-A"), "origin must be in toast: {msg}"); + assert!(msg.contains("proj-B"), "target must be in toast: {msg}"); + } + + #[test] + fn test_glance_owner_mismatch_toast_contains_owner_name() { + let (msg, _) = glance_owner_mismatch_toast("owner-X", "active-Y"); + assert!(msg.contains("owner-X"), "owner must be in toast: {msg}"); + assert!(msg.contains("active-Y"), "active must be in toast: {msg}"); + } + + #[test] + fn test_form_mismatch_toast_contains_field_label() { + let (msg, _) = form_mismatch_toast("network", "proj-B", "proj-A"); + assert!( + msg.contains("network"), + "field label must be in toast: {msg}" + ); + assert!(msg.contains("proj-B")); + assert!(msg.contains("proj-A")); + } + + #[test] + fn test_toast_level_is_error() { + // Phase 7 Step 11c parity — cross-project blocks land on the + // Error TTL so the message survives the standard Success/Info + // auto-dismiss windows. + let (_, lvl1) = origin_mismatch_toast("a", "b"); + let (_, lvl2) = glance_owner_mismatch_toast("o", "a"); + let (_, lvl3) = form_mismatch_toast("f", "s", "a"); + assert_eq!(lvl1, ToastLevel::Error); + assert_eq!(lvl2, ToastLevel::Error); + assert_eq!(lvl3, ToastLevel::Error); + } + + #[test] + fn test_toast_respects_60char_truncate() { + // 100-char project id should not survive verbatim in the toast. + let long = "a".repeat(100); + let (msg, _) = origin_mismatch_toast(&long, "proj-B"); + // Original 100-char run must not appear; the truncated form + // (59 'a' chars + '…') will. + assert!(!msg.contains(&"a".repeat(100))); + let truncated = format!("{}…", "a".repeat(59)); + assert!( + msg.contains(&truncated), + "expected 60-char truncate with ellipsis, got: {msg}" + ); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d7f85df..7c8d9b1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ pub mod activity_log; pub mod confirm; pub mod context_indicator; +pub mod cross_project_toast; pub mod detail_view; pub mod form; pub mod gauge_bar; diff --git a/src/worker.rs b/src/worker.rs index 20c75b7..baf3f15 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::future::Future; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use chrono::{DateTime, Utc}; use tokio::sync::mpsc; @@ -12,11 +12,15 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::Instrument; -use crate::action::Action; +use crate::action::{Action, DispatchedAction}; use crate::adapter::registry::AdapterRegistry; use crate::context::{Epoch, VersionedEvent}; use crate::event::AppEvent; +use crate::infra::audit::AuditLogger; +use crate::infra::cross_project_audit::{self, CrossProjectBlockEvent}; +use crate::infra::cross_project_guard::{self, CrossProjectReason, GuardDecision, GuardLayer}; use crate::infra::rbac::{ActionKind, RbacGuard}; +use crate::models::glance::Image; use crate::port::types::*; /// Spawn an async future and forward its `AppEvent` result to `event_tx` @@ -49,19 +53,50 @@ where /// Run the background worker loop. /// Receives Actions from `action_rx`, calls the appropriate API via `registry`, /// and sends resulting AppEvents to `event_tx`. +/// +/// `audit_logger` and `actor_ctx` are FR2 (BL-P2-085) wiring: when an origin +/// guard rejects a mutation, the worker emits a structured +/// `CrossProjectBlockEvent` through the shared `AuditLogger` instance. +/// `actor_ctx` is read live at each emit so a runtime cloud-switch is +/// reflected in subsequent audit entries. +#[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all)] pub async fn run_worker( registry: Arc, rbac: Arc, all_tenants: Arc, - mut action_rx: mpsc::UnboundedReceiver>, + mut action_rx: mpsc::UnboundedReceiver>, event_tx: mpsc::UnboundedSender>, + audit_logger: Option>, + actor_ctx: Arc>, ) { let polling_servers: Arc>> = Arc::new(Mutex::new(HashSet::new())); let in_flight_fetches: Arc>> = Arc::new(Mutex::new(HashSet::new())); while let Some(envelope) = action_rx.recv().await { - let (action, action_epoch) = envelope.into_parts(); + let (dispatched, action_epoch) = envelope.into_parts(); + + // BL-P2-085 Step 11a/b/c: gate mutations against the live active + // scope, emit a structured audit entry, and surface a UI toast via + // `AppEvent::CrossProjectBlocked`. Audit is emitted first so it lands + // even when the receiver has been dropped; the UI event follows on + // the same epoch so any concurrent `ApiError` for this dispatch is + // preceded by the block notification. + if let GuardDecision::Block { reason } = check_dispatched_origin(&dispatched, &rbac) { + let block_event = make_cross_project_blocked_event(&reason, &dispatched.action); + emit_origin_block_audit( + reason, + &dispatched, + &rbac, + audit_logger.as_deref(), + &actor_ctx, + action_epoch, + ); + let _ = event_tx.send(VersionedEvent::new(block_event, action_epoch)); + continue; + } + + let action = dispatched.action; // RBAC guard: check CUD permissions before API call if let Some(kind) = action_to_kind(&action) @@ -92,6 +127,17 @@ pub async fn run_worker( let all_tenants = all_tenants.clone(); let polling_servers = polling_servers.clone(); let in_flight_fetches = in_flight_fetches.clone(); + // Step 16 (FR4 Phase 9): the spawned task needs audit_logger / + // actor_ctx so a pre-mutation form-block can emit its + // `AdapterFilterViolation` / `FormSelectionMismatch` audit + // entry without re-resolving them from globals. + let audit_logger_task = audit_logger.clone(); + let actor_ctx_task = actor_ctx.clone(); + + // BL-P2-085 Step 12: snapshot active project once per dispatch and + // pass it to handle_action so Neutron list builders inject + // `tenant_id={scope}` when `all_tenants=false`. + let active_tenant = rbac.project_id(); let poll_migration_id = poll_migration_server_id(&action); let poll_status_id = poll_server_id_for_status(&action); @@ -99,7 +145,15 @@ pub async fn run_worker( let span = tracing::info_span!("worker_task", action = action_name(&action)); tokio::spawn( async move { - let event = handle_action(®istry, &all_tenants, action).await; + let event = handle_action( + ®istry, + &all_tenants, + active_tenant, + action, + audit_logger_task.as_deref(), + &actor_ctx_task, + ) + .await; let success = event .as_ref() .is_some_and(|ev| !matches!(ev, AppEvent::ApiError { .. })); @@ -148,7 +202,12 @@ pub async fn run_worker( /// Map an Action to its RBAC ActionKind for permission checking. /// Returns None for read-only/UI actions that need no guard. -fn action_to_kind(action: &Action) -> Option { +/// Map an [`Action`] to its RBAC [`ActionKind`]. Returns `None` for read-only, +/// UI, system, and orchestration actions that do not pass through RBAC gating. +/// +/// Exhaustive match — adding a new `Action` variant breaks compilation here +/// (BL-P2-085 Step 7), forcing a deliberate classification decision. +pub(crate) fn action_to_kind(action: &Action) -> Option { match action { // Create (member-level) Action::CreateServer(_) @@ -221,11 +280,223 @@ fn action_to_kind(action: &Action) -> Option { Some(ActionKind::ForceDelete) } - // Read / UI / System — no guard - _ => None, + // --- Explicit None: not RBAC-gated mutations --- + // Read-only fetches + Action::FetchServers + | Action::FetchFlavors + | Action::FetchAggregates + | Action::FetchComputeServices + | Action::FetchHypervisors + | Action::FetchNetworks + | Action::FetchSecurityGroups + | Action::FetchFloatingIps + | Action::FetchSubnets { .. } + | Action::FetchAgents + | Action::FetchVolumes + | Action::FetchSnapshots + | Action::FetchImages + | Action::FetchProjects + | Action::FetchUsers + | Action::FetchUsage { .. } + | Action::FetchMigrationProgress { .. } + | Action::FetchPorts { .. } + | Action::FetchPortBindingsForServer { .. } => None, + + // Navigation / UI helpers + Action::Navigate(_) + | Action::Back + | Action::FocusSidebar + | Action::EnterFormMode + | Action::ExitFormMode + | Action::SelectResource { .. } + | Action::NavigateToResource { .. } + | Action::ShowToast { .. } => None, + + // System / global state + Action::RefreshAll | Action::Quit | Action::ToggleAllTenants => None, + + // Context switch — orchestration, not RBAC mutation + Action::SwitchContext(_) | Action::SwitchBack => None, } } +/// Wrapper over [`action_to_kind`]: true if the action is an RBAC-gated +/// mutation. Wired up by [`crate::context::ActionSender`] in BL-P2-085 Phase 6 +/// Wired by `ActionSender::send` (Step 9) to decide whether to stamp +/// `origin_project_id` on the outgoing `DispatchedAction`. +pub(crate) fn action_is_mutation(action: &Action) -> bool { + action_to_kind(action).is_some() +} + +/// FR2 (BL-P2-085 Step 11a): compare a dispatched action's `origin_project_id` +/// against the live active scope on `RbacGuard`. +/// +/// Read-only (unstamped) actions return [`GuardDecision::Allow`]. Stamped +/// actions defer to [`cross_project_guard::check_origin_scope`], which +/// fail-safe blocks empty/unscoped values. Sync — callable in unit tests +/// without spawning the worker loop. Step 11b wires `AuditLogger::emit` +/// on `Block`; Step 11c will add toast emission. +pub(crate) fn check_dispatched_origin( + dispatched: &DispatchedAction, + rbac: &RbacGuard, +) -> GuardDecision { + match &dispatched.origin_project_id { + Some(origin) => { + let active = rbac.project_id().unwrap_or_default(); + cross_project_guard::check_origin_scope(origin, &active) + } + None => GuardDecision::Allow, + } +} + +/// FR2 (BL-P2-085 Phase 7 폴리싱): live actor identity used by the worker when +/// emitting `CrossProjectBlockEvent`s. Wrapped in `Arc>` so a +/// runtime cloud-switch (BL-P2-074) can update the active cloud without +/// re-spawning the worker. Without this, audit entries stay anchored to the +/// startup cloud forever. +/// +/// `user_id` falls back to `"unknown"` (matches `App::build_audit_entry` line +/// 814) when the configured credential lacks an explicit username — empty +/// strings would silently break audit attribution. +#[derive(Debug, Clone)] +pub struct ActorContext { + pub cloud: String, + pub user_id: String, +} + +/// FR2 (BL-P2-085 Step 11c): build the user-facing `AppEvent::CrossProjectBlocked` +/// payload from a guard reason and the offending action. The String fields +/// keep the variant decoupled from `cross_project_guard` so the UI layer +/// doesn't need to import the guard module. +pub(crate) fn make_cross_project_blocked_event( + reason: &CrossProjectReason, + action: &Action, +) -> AppEvent { + AppEvent::CrossProjectBlocked { + reason: reason.as_str().to_string(), + action: action_name(action).to_string(), + } +} + +/// FR2 (BL-P2-085 Step 11b, 폴리싱): build a [`CrossProjectBlockEvent`] from a +/// worker origin-mismatch decision and emit it via +/// [`cross_project_audit::emit`]. +/// +/// Reads `actor_ctx` at call time so a cloud-switch landing between worker +/// spawn and the next block is reflected in the audit entry. +/// +/// Best-effort: when `audit_logger` is `None`, `emit` falls back to +/// `tracing::warn!` so the block still surfaces in process logs. Sync — +/// callable in unit tests without spawning the worker loop. +/// +/// `resource_kind` / `resource_id` / `target_project_id` are left blank/None +/// for now; a follow-up enrichment pass will populate them per-action so +/// audit consumers can slice on `details.resource_kind`. Until then they +/// grep on `action_type` + `details.guard_layer`. +pub(crate) fn emit_origin_block_audit( + reason: CrossProjectReason, + dispatched: &DispatchedAction, + rbac: &RbacGuard, + audit_logger: Option<&AuditLogger>, + actor_ctx: &Arc>, + correlation_id: u64, +) { + let actor = actor_ctx + .read() + .map(|guard| (guard.cloud.clone(), guard.user_id.clone())) + .unwrap_or_else(|e| { + let guard = e.into_inner(); + (guard.cloud.clone(), guard.user_id.clone()) + }); + let event = CrossProjectBlockEvent::new( + reason, + GuardLayer::Fr2Worker, + action_name(&dispatched.action), + "", // resource_kind: enriched in follow-up pass + actor.0, + actor.1, + rbac.project_id(), + dispatched.origin_project_id.clone(), + correlation_id, + ); + cross_project_audit::emit(&event, audit_logger); +} + +/// FR4 (BL-P2-085 Step 16, Phase 9): pure decision over a refetched +/// Glance image. `image.owner` carries the project id (Glance schema); +/// `None` is treated as fail-safe deny so an upstream that omits owner +/// cannot route a cross-project delete past the form layer. +pub(crate) fn check_image_owner_scope(image: &Image, active: &str) -> GuardDecision { + // Parity with `RefilterScope::strict` / `validate_form_scope` — an + // empty active combined with `owner == Some("")` would silent-allow + // a cross-project mutation. Caught in dev builds; production + // callers must filter `Some("")` to `None` before reaching here + // (DeleteImage branch wraps with `active_tenant.as_deref()` and + // `RefilterScope::from_parts` normalization handles the same case + // for the adapter list path). + debug_assert!( + !active.is_empty(), + "check_image_owner_scope requires a non-empty active project id — empty would silent-allow Some(\"\")" + ); + match image.owner.as_deref() { + Some(owner) if owner == active => GuardDecision::Allow, + Some(owner) => GuardDecision::Block { + reason: CrossProjectReason::FormSelectionMismatch { + selected: owner.to_string(), + active: active.to_string(), + }, + }, + None => GuardDecision::Block { + reason: CrossProjectReason::FormSelectionMismatch { + selected: String::new(), + active: active.to_string(), + }, + }, + } +} + +/// FR4 (BL-P2-085 Step 16, Phase 9): build a [`CrossProjectBlockEvent`] +/// for a form-layer block (`GuardLayer::Fr4Form`) and emit via +/// [`cross_project_audit::emit`]. Mirrors [`emit_origin_block_audit`] +/// for the FR2 path; the worker uses this when pre-mutation GET shows +/// the selected resource lives in another project. +/// +/// `resource_id` is stamped on the top-level event so the audit +/// fingerprint stays unique per row; `asserted_origin_project_id` is +/// `None` because FR4 has no origin to compare against. +#[allow(clippy::too_many_arguments)] +pub(crate) fn emit_form_block_audit( + reason: CrossProjectReason, + action_type: &str, + resource_kind: &str, + resource_id: &str, + active_project_id: Option, + audit_logger: Option<&AuditLogger>, + actor_ctx: &Arc>, + correlation_id: u64, +) { + let (cloud, user_id) = actor_ctx + .read() + .map(|g| (g.cloud.clone(), g.user_id.clone())) + .unwrap_or_else(|e| { + let g = e.into_inner(); + (g.cloud.clone(), g.user_id.clone()) + }); + let mut event = CrossProjectBlockEvent::new( + reason, + GuardLayer::Fr4Form, + action_type, + resource_kind, + cloud, + user_id, + active_project_id, + None, // FR4: no origin to assert + correlation_id, + ); + event.resource_id = Some(resource_id.to_string()); + cross_project_audit::emit(&event, audit_logger); +} + /// Human-readable name for an Action, used in PermissionDenied messages. fn action_name(action: &Action) -> &str { match action { @@ -282,12 +553,19 @@ fn action_name(action: &Action) -> &str { async fn handle_action( registry: &AdapterRegistry, all_tenants: &AtomicBool, + active_tenant: Option, action: Action, + audit_logger: Option<&AuditLogger>, + actor_ctx: &Arc>, ) -> Option { let action_label = action_name(&action); tracing::info!(action = action_label, "handling action"); let default_pagination = PaginationParams::default(); let at = all_tenants.load(Ordering::Relaxed); + // BL-P2-085 Step 12: when admin explicitly toggled `all_tenants` we omit + // tenant_id; otherwise inject the live active scope so server-side scoping + // matches the worker mutation guard's view of the world. + let scoped_tenant_id = if at { None } else { active_tenant.clone() }; match action { // -- Nova: Servers -------------------------------------------------- @@ -481,7 +759,13 @@ async fn handle_action( Action::FetchNetworks => { match registry .neutron - .list_networks(&NetworkListFilter { all_tenants: at }, &default_pagination) + .list_networks( + &NetworkListFilter { + all_tenants: at, + tenant_id: scoped_tenant_id.clone(), + }, + &default_pagination, + ) .await { Ok(resp) => Some(AppEvent::NetworksLoaded(resp.items)), @@ -507,7 +791,10 @@ async fn handle_action( match registry .neutron .list_security_groups( - &SecurityGroupListFilter { all_tenants: at }, + &SecurityGroupListFilter { + all_tenants: at, + tenant_id: scoped_tenant_id.clone(), + }, &default_pagination, ) .await @@ -546,7 +833,10 @@ async fn handle_action( match registry .neutron .list_floating_ips( - &FloatingIpListFilter { all_tenants: at }, + &FloatingIpListFilter { + all_tenants: at, + tenant_id: scoped_tenant_id.clone(), + }, &default_pagination, ) .await @@ -658,10 +948,49 @@ async fn handle_action( Ok(img) => Some(AppEvent::ImageCreated(img)), Err(e) => Some(api_error("CreateImage", e)), }, - Action::DeleteImage { id } => match registry.glance.delete_image(&id).await { - Ok(()) => Some(AppEvent::ImageDeleted { id }), - Err(e) => Some(api_error("DeleteImage", e)), - }, + Action::DeleteImage { id } => { + // FR4 (BL-P2-085 Step 16, Phase 9): pre-mutation owner check. + // Refetch the image to read `owner` live — a list snapshot in + // the UI can be stale by the time the user confirms delete. + // + // Unscoped session: FR2 does NOT block unstamped envelopes + // (check_dispatched_origin returns Allow when origin=None), + // and this branch also skips FR4 because `active_tenant` is + // None. RBAC `can_perform` is the only check; project-scope + // verification is intentionally absent because the user has + // not yet authenticated to a project. The pre-auth gate is + // the App-layer router's responsibility, not FR4's. + // + // Pre-GET failure: silent passthrough — the subsequent + // `delete_image` call surfaces the underlying error via + // `api_error`. Trade-off: a 401/403 on GET (e.g. token + // expired) could in principle route past FR4 to delete; a + // future cycle (BL follow-up) can fail-closed for 4xx-auth + // / 5xx while keeping 404 as the existing pass-through. + if let Some(active) = active_tenant.as_deref() + && let Ok(image) = registry.glance.get_image(&id).await + && let GuardDecision::Block { reason } = check_image_owner_scope(&image, active) + { + emit_form_block_audit( + reason.clone(), + "DeleteImage", + "image", + &id, + Some(active.to_string()), + audit_logger, + actor_ctx, + 0, // correlation_id: form layer not bound to a dispatch epoch + ); + return Some(AppEvent::CrossProjectBlocked { + reason: reason.as_str().to_string(), + action: "DeleteImage".to_string(), + }); + } + match registry.glance.delete_image(&id).await { + Ok(()) => Some(AppEvent::ImageDeleted { id }), + Err(e) => Some(api_error("DeleteImage", e)), + } + } // -- Keystone: Projects --------------------------------------------- Action::FetchProjects => match registry.keystone.list_projects(&default_pagination).await { @@ -875,6 +1204,16 @@ fn fetch_dedup_key(action: &Action) -> Option<&'static str> { use crate::models::common::is_terminal_server_status; /// Poll server status every 2 seconds until it reaches a terminal state. +/// +/// BL-P2-085 Step 18 (Phase 11) — background read-only contract: this +/// task sends `AppEvent::ServerStatusPolled` directly on the event +/// channel and **never** dispatches a mutation through `ActionSender`. +/// FR2 origin-stamping therefore does not apply here; cross-project +/// scope checks remain a property of the user-initiated mutation path +/// (worker recv loop). If a future refactor needs the polling task to +/// emit mutation `Action`s, route them through `ActionSender::send` so +/// the Step-9 central stamping covers them automatically — do not +/// hand-craft `DispatchedAction`s in the polling body. async fn poll_server_status( registry: &AdapterRegistry, event_tx: &mpsc::UnboundedSender>, @@ -905,6 +1244,13 @@ async fn poll_server_status( } /// Poll migration progress every 2 seconds until completed or error. +/// +/// BL-P2-085 Step 18 (Phase 11) — background read-only contract: same +/// invariant as [`poll_server_status`]. The two `AppEvent` variants +/// emitted here (`MigrationProgressLoaded` / `MigrationPollingStopped`) +/// flow on the event channel directly and bypass the FR2 stamping +/// path. Adding mutation dispatch to this body must go through +/// `ActionSender::send` (Step 9 central stamping). async fn poll_migration_progress( registry: &AdapterRegistry, event_tx: &mpsc::UnboundedSender>, @@ -1071,6 +1417,757 @@ mod tests { ); } + // --- BL-P2-085 Step 7: action_to_kind exhaustive + action_is_mutation --- + + #[test] + fn test_action_to_kind_fetch_and_nav_variants_return_none() { + // Fetch* + Navigate/Back + UI helpers + system + context switch all + // must return None — they are not RBAC-gated mutations. + let none_actions = vec![ + Action::Navigate(crate::models::common::Route::Servers), + Action::Back, + Action::FetchServers, + Action::FetchFlavors, + Action::FetchAggregates, + Action::FetchComputeServices, + Action::FetchHypervisors, + Action::FetchNetworks, + Action::FetchSecurityGroups, + Action::FetchFloatingIps, + Action::FetchSubnets { + network_id: "n1".into(), + }, + Action::FetchAgents, + Action::FetchVolumes, + Action::FetchSnapshots, + Action::FetchImages, + Action::FetchProjects, + Action::FetchUsers, + Action::FetchUsage { + start: "s".into(), + end: "e".into(), + }, + Action::FetchPorts { + server_id: "s1".into(), + }, + Action::FocusSidebar, + Action::EnterFormMode, + Action::ExitFormMode, + Action::SelectResource { id: "x".into() }, + Action::NavigateToResource { + route: crate::models::common::Route::Volumes, + id: "v1".into(), + }, + Action::ToggleAllTenants, // UI state toggle, not backend mutation + Action::ShowToast { + message: "hi".into(), + }, + Action::RefreshAll, + Action::Quit, + Action::SwitchBack, // orchestration, not RBAC mutation + ]; + for a in &none_actions { + assert_eq!( + action_to_kind(a), + None, + "{:?} must return None (not a mutation)", + std::mem::discriminant(a) + ); + } + } + + #[test] + fn test_action_to_kind_all_mutations_have_kind() { + use crate::port::types::*; + let mutations: Vec = vec![ + Action::CreateServer(ServerCreateParams { + name: "t".into(), + image_id: "i".into(), + flavor_id: "f".into(), + networks: vec![], + security_groups: None, + key_name: None, + availability_zone: None, + }), + Action::DeleteServer { + id: "s".into(), + name: "n".into(), + }, + Action::RebootServer { + id: "s".into(), + hard: false, + }, + Action::StartServer { id: "s".into() }, + Action::StopServer { id: "s".into() }, + Action::CreateServerSnapshot { + server_id: "s".into(), + name: "snap".into(), + }, + Action::CreateFlavor(FlavorCreateParams { + name: "f".into(), + vcpus: 1, + ram_mb: 1, + disk_gb: 1, + is_public: true, + }), + Action::DeleteFlavor { id: "f".into() }, + Action::CreateNetwork(NetworkCreateParams { + name: "n".into(), + admin_state_up: true, + shared: None, + external: None, + mtu: None, + port_security_enabled: None, + }), + Action::CreateSecurityGroup(SecurityGroupCreateParams { + name: "sg".into(), + description: None, + }), + Action::DeleteSecurityGroup { id: "sg".into() }, + Action::CreateSecurityGroupRule(SecurityGroupRuleCreateParams { + security_group_id: "sg".into(), + direction: RuleDirection::Ingress, + protocol: None, + port_range_min: None, + port_range_max: None, + remote_ip_prefix: None, + remote_group_id: None, + ethertype: None, + }), + Action::DeleteSecurityGroupRule { + rule_id: "r".into(), + }, + Action::CreateFloatingIp { + network_id: "n".into(), + }, + Action::DeleteFloatingIp { id: "f".into() }, + Action::CreateVolume(VolumeCreateParams { + name: "v".into(), + size_gb: 1, + volume_type: None, + description: None, + availability_zone: None, + }), + Action::DeleteVolume { + id: "v".into(), + force: false, + }, + Action::ExtendVolume { + id: "v".into(), + new_size: 2, + }, + Action::CreateSnapshot(SnapshotCreateParams { + name: "sn".into(), + volume_id: "v".into(), + description: None, + force: false, + }), + Action::DeleteSnapshot { id: "s".into() }, + Action::CreateImage(ImageCreateParams { + name: "img".into(), + disk_format: "qcow2".into(), + container_format: "bare".into(), + visibility: None, + min_disk: None, + min_ram: None, + }), + Action::DeleteImage { id: "i".into() }, + Action::CreateProject(ProjectCreateParams { + name: "p".into(), + description: None, + domain_id: "default".into(), + enabled: Some(true), + }), + Action::DeleteProject { id: "p".into() }, + Action::CreateUser(UserCreateParams { + name: "u".into(), + password: "pw".into(), + email: None, + domain_id: "default".into(), + enabled: Some(true), + default_project_id: None, + }), + Action::DeleteUser { id: "u".into() }, + Action::ResizeServer { + id: "s".into(), + flavor_id: "f".into(), + }, + Action::ConfirmResize { id: "s".into() }, + Action::RevertResize { id: "s".into() }, + Action::LiveMigrateServer { + id: "s".into(), + host: None, + }, + Action::ColdMigrateServer { id: "s".into() }, + Action::ConfirmMigration { id: "s".into() }, + Action::RevertMigration { id: "s".into() }, + Action::EvacuateServer { + id: "s".into(), + params: EvacuateParams::default(), + }, + Action::DisableComputeService { + service_id: "svc".into(), + hostname: "h".into(), + }, + Action::EnableComputeService { + service_id: "svc".into(), + hostname: "h".into(), + }, + Action::AttachVolume { + volume_id: "v".into(), + server_id: "s".into(), + device: None, + }, + Action::DetachVolume { + volume_id: "v".into(), + server_id: "s".into(), + attachment_id: "a".into(), + }, + Action::ForceDetachVolume { + volume_id: "v".into(), + server_id: "s".into(), + attachment_id: "a".into(), + }, + Action::ForceResetVolumeState { + volume_id: "v".into(), + target_state: "available".into(), + }, + Action::AssociateFloatingIp { + fip_id: "f".into(), + port_id: "p".into(), + }, + Action::DisassociateFloatingIp { fip_id: "f".into() }, + ]; + for a in &mutations { + assert!( + action_to_kind(a).is_some(), + "{:?} must map to Some(ActionKind)", + std::mem::discriminant(a) + ); + } + } + + #[test] + fn test_action_to_kind_rbac_mapping_lockstep() { + // Explicit lockstep — each mutation variant maps to the documented + // ActionKind. Catches accidental reclassification. + use crate::infra::rbac::ActionKind; + use crate::port::types::*; + + let cases: Vec<(Action, ActionKind)> = vec![ + ( + Action::CreateServer(ServerCreateParams { + name: "t".into(), + image_id: "i".into(), + flavor_id: "f".into(), + networks: vec![], + security_groups: None, + key_name: None, + availability_zone: None, + }), + ActionKind::Create, + ), + ( + Action::DeleteVolume { + id: "v".into(), + force: true, + }, + ActionKind::ForceDelete, + ), + ( + Action::DeleteVolume { + id: "v".into(), + force: false, + }, + ActionKind::Delete, + ), + ( + Action::ResizeServer { + id: "s".into(), + flavor_id: "f".into(), + }, + ActionKind::Resize, + ), + ( + Action::LiveMigrateServer { + id: "s".into(), + host: None, + }, + ActionKind::Migrate, + ), + ( + Action::EvacuateServer { + id: "s".into(), + params: EvacuateParams::default(), + }, + ActionKind::Evacuate, + ), + ( + Action::DisableComputeService { + service_id: "svc".into(), + hostname: "h".into(), + }, + ActionKind::EnableDisable, + ), + ( + Action::CreateProject(ProjectCreateParams { + name: "p".into(), + description: None, + domain_id: "default".into(), + enabled: Some(true), + }), + ActionKind::ManageQuota, + ), + ( + Action::AttachVolume { + volume_id: "v".into(), + server_id: "s".into(), + device: None, + }, + ActionKind::Attach, + ), + ( + Action::DetachVolume { + volume_id: "v".into(), + server_id: "s".into(), + attachment_id: "a".into(), + }, + ActionKind::Detach, + ), + ( + Action::ForceDetachVolume { + volume_id: "v".into(), + server_id: "s".into(), + attachment_id: "a".into(), + }, + ActionKind::ForceDelete, + ), + ]; + for (action, expected) in &cases { + assert_eq!( + action_to_kind(action), + Some(*expected), + "RBAC lockstep mismatch for {:?}", + std::mem::discriminant(action) + ); + } + } + + #[test] + fn test_action_is_mutation_helper_parity() { + let m = Action::DeleteServer { + id: "s".into(), + name: "n".into(), + }; + let r = Action::FetchServers; + assert!(action_is_mutation(&m)); + assert!(!action_is_mutation(&r)); + // Wrapper parity: action_is_mutation == action_to_kind.is_some() + for a in [&m, &r] { + assert_eq!(action_is_mutation(a), action_to_kind(a).is_some()); + } + } + + // --- BL-P2-085 Step 11a: worker origin/active guard hook --- + + fn rbac_with_project(project_id: &str) -> RbacGuard { + use crate::port::types::TokenRole; + let guard = RbacGuard::new(); + guard.update_roles( + vec![TokenRole { + id: "member-id".into(), + name: "member".into(), + }], + Some(project_id.into()), + ); + guard + } + + fn actor_ctx_with(cloud: &str, user_id: &str) -> Arc> { + Arc::new(RwLock::new(ActorContext { + cloud: cloud.into(), + user_id: user_id.into(), + })) + } + + #[test] + fn test_worker_allows_mutation_when_origin_matches() { + use crate::infra::cross_project_guard::GuardDecision; + + let rbac = rbac_with_project("p-active"); + let dispatched = DispatchedAction::stamped( + Action::DeleteServer { + id: "s1".into(), + name: "n".into(), + }, + "p-active".into(), + ); + assert_eq!( + check_dispatched_origin(&dispatched, &rbac), + GuardDecision::Allow, + ); + } + + #[test] + fn test_worker_blocks_mutation_when_origin_mismatch() { + use crate::infra::cross_project_guard::{CrossProjectReason, GuardDecision}; + + let rbac = rbac_with_project("p-active"); + let dispatched = DispatchedAction::stamped( + Action::DeleteServer { + id: "s1".into(), + name: "n".into(), + }, + "p-stale".into(), + ); + match check_dispatched_origin(&dispatched, &rbac) { + GuardDecision::Block { + reason: + CrossProjectReason::OriginScopeMismatch { + ref origin, + ref active, + }, + } => { + assert_eq!(origin, "p-stale"); + assert_eq!(active, "p-active"); + } + other => panic!("expected OriginScopeMismatch Block, got {other:?}"), + } + } + + // --- BL-P2-085 Step 11b: AuditLogger integration on Block --- + + #[test] + fn test_emit_origin_block_audit_writes_entry_when_logger_present() { + use crate::infra::audit::AuditLogger; + use crate::infra::cross_project_guard::CrossProjectReason; + + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("audit.log"); + let logger = AuditLogger::new(path.clone()).unwrap(); + + let rbac = rbac_with_project("p-active"); + let dispatched = DispatchedAction::stamped( + Action::DeleteServer { + id: "s1".into(), + name: "n".into(), + }, + "p-stale".into(), + ); + let reason = CrossProjectReason::OriginScopeMismatch { + origin: "p-stale".into(), + active: "p-active".into(), + }; + let actor = actor_ctx_with("devstack", "user-uuid"); + + emit_origin_block_audit(reason, &dispatched, &rbac, Some(&logger), &actor, 42); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!( + !content.is_empty(), + "audit log must contain entry after Block emit" + ); + let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(parsed["action"], "DeleteServer"); + assert_eq!(parsed["cloud"], "devstack"); + assert_eq!(parsed["user"], "user-uuid"); + assert_eq!(parsed["project"], "p-active"); + assert_eq!( + parsed["result"], + serde_json::json!({ "failed": "cross_project_block:origin_scope_mismatch" }), + ); + assert_eq!(parsed["details"]["guard_layer"], "fr2_worker"); + assert_eq!(parsed["details"]["correlation_id"], 42); + assert_eq!(parsed["details"]["asserted_origin_project_id"], "p-stale"); + } + + #[test] + fn test_emit_origin_block_audit_does_not_panic_when_logger_none() { + use crate::infra::cross_project_guard::CrossProjectReason; + + let rbac = rbac_with_project("p-active"); + let dispatched = DispatchedAction::stamped( + Action::DeleteServer { + id: "s1".into(), + name: "n".into(), + }, + "p-stale".into(), + ); + let reason = CrossProjectReason::OriginScopeMismatch { + origin: "p-stale".into(), + active: "p-active".into(), + }; + let actor = actor_ctx_with("devstack", "user-uuid"); + + // logger=None must remain best-effort — the worker must still block, + // and emit() falls back to tracing without panicking. + emit_origin_block_audit(reason, &dispatched, &rbac, None, &actor, 7); + } + + #[test] + fn test_emit_origin_block_audit_picks_up_actor_context_mutation() { + use crate::infra::audit::AuditLogger; + use crate::infra::cross_project_guard::CrossProjectReason; + + // Phase 7 폴리싱: a runtime cloud-switch (BL-P2-074) mutates + // `ActorContext.cloud` in place via the shared `RwLock`. The next + // worker block emit must reflect the new cloud — without live read, + // audit entries stay anchored to the worker's spawn cloud forever. + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("audit.log"); + let logger = AuditLogger::new(path.clone()).unwrap(); + + let rbac = rbac_with_project("p-active"); + let dispatched = || { + DispatchedAction::stamped( + Action::DeleteServer { + id: "s1".into(), + name: "n".into(), + }, + "p-stale".into(), + ) + }; + let reason_a = CrossProjectReason::OriginScopeMismatch { + origin: "p-stale".into(), + active: "p-active".into(), + }; + let reason_b = reason_a.clone(); + let actor = actor_ctx_with("cloud-A", "user-uuid"); + + emit_origin_block_audit(reason_a, &dispatched(), &rbac, Some(&logger), &actor, 1); + + // Cloud-switch arrives between dispatches — App's ContextChanged + // handler updates the shared RwLock. + actor + .write() + .expect("actor_ctx write lock") + .cloud + .replace_range(.., "cloud-B"); + + emit_origin_block_audit(reason_b, &dispatched(), &rbac, Some(&logger), &actor, 2); + + let content = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2, "expected 2 audit lines, got: {content}"); + let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!( + first["cloud"], "cloud-A", + "first emit must use the spawn-time cloud" + ); + assert_eq!( + second["cloud"], "cloud-B", + "second emit must reflect post-switch cloud (live RwLock read)" + ); + } + + // --- BL-P2-085 Step 16 (Phase 9): FR4 form-layer image-owner guard --- + + fn sample_image(id: &str, owner: Option<&str>) -> Image { + Image { + id: id.to_string(), + name: "img".to_string(), + status: "active".to_string(), + disk_format: None, + container_format: None, + size: None, + visibility: "private".to_string(), + min_disk: 0, + min_ram: 0, + checksum: None, + created_at: None, + owner: owner.map(str::to_string), + } + } + + #[test] + fn test_check_image_owner_scope_match_allows() { + let img = sample_image("img-1", Some("proj-A")); + assert_eq!( + check_image_owner_scope(&img, "proj-A"), + GuardDecision::Allow + ); + } + + #[test] + fn test_check_image_owner_scope_mismatch_blocks() { + let img = sample_image("img-1", Some("proj-B")); + match check_image_owner_scope(&img, "proj-A") { + GuardDecision::Block { reason } => match reason { + CrossProjectReason::FormSelectionMismatch { selected, active } => { + assert_eq!(selected, "proj-B"); + assert_eq!(active, "proj-A"); + } + other => panic!("expected FormSelectionMismatch, got {other:?}"), + }, + GuardDecision::Allow => panic!("mismatch must block"), + } + } + + #[test] + fn test_check_image_owner_scope_missing_owner_fail_safe() { + // Glance omitted `owner` → can't prove same-project → fail-safe deny. + let img = sample_image("img-2", None); + match check_image_owner_scope(&img, "proj-A") { + GuardDecision::Block { reason } => match reason { + CrossProjectReason::FormSelectionMismatch { selected, active } => { + assert_eq!(selected, "", "None owner encodes as empty selected"); + assert_eq!(active, "proj-A"); + } + other => panic!("expected FormSelectionMismatch, got {other:?}"), + }, + GuardDecision::Allow => panic!("missing owner must fail-safe deny"), + } + } + + // cargo-review branch-full Correctness #5 (S-class follow-up): + // RefilterScope::strict / scope_validator::validate_form_scope parity — + // empty active must trip in dev so a leaked `Some("")` from the token + // doesn't quietly Allow because `owner == Some("")` happens to match. + #[test] + #[should_panic(expected = "non-empty active")] + fn test_check_image_owner_scope_panics_on_empty_active() { + let img = sample_image("img-1", Some("")); + let _ = check_image_owner_scope(&img, ""); + } + + #[test] + fn test_emit_form_block_audit_writes_entry_with_fr4_form_layer() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("audit.log"); + let logger = AuditLogger::new(path.clone()).unwrap(); + let actor = actor_ctx_with("devstack", "user-uuid"); + + let reason = CrossProjectReason::FormSelectionMismatch { + selected: "proj-B".into(), + active: "proj-A".into(), + }; + emit_form_block_audit( + reason, + "DeleteImage", + "image", + "img-99", + Some("proj-A".to_string()), + Some(&logger), + &actor, + 17, + ); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(!content.is_empty(), "audit log must contain entry"); + let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(parsed["action"], "DeleteImage"); + assert_eq!(parsed["resource_id"], "img-99"); + assert_eq!(parsed["details"]["guard_layer"], "fr4_form"); + assert_eq!(parsed["details"]["correlation_id"], 17); + assert_eq!( + parsed["result"], + serde_json::json!({ "failed": "cross_project_block:form_selection_mismatch" }) + ); + } + + #[test] + fn test_emit_form_block_audit_does_not_panic_when_logger_none() { + // logger=None must remain best-effort (tracing::warn! fallback). + let actor = actor_ctx_with("devstack", "user-uuid"); + let reason = CrossProjectReason::FormSelectionMismatch { + selected: "proj-B".into(), + active: "proj-A".into(), + }; + emit_form_block_audit( + reason, + "DeleteImage", + "image", + "img-99", + Some("proj-A".to_string()), + None, + &actor, + 1, + ); + } + + // --- BL-P2-085 Step 18 (Phase 11): background poll read-only audit --- + // + // The plan-time worry was that background pollers might dispatch + // mutation actions with a stale scope. Inspection shows the two + // existing pollers (`poll_server_status` / `poll_migration_progress`) + // only send `AppEvent`s on `event_tx` — never `ActionSender::send`. + // The architectural pin lives on the two function-level doc comments + // (`poll_server_status` / `poll_migration_progress`): any future + // change that needs the polling task to dispatch a mutation must + // route through `ActionSender::send` so the Step-9 central stamping + // handles origin automatically. Hand-built `DispatchedAction`s + // inside poll bodies are forbidden. + // + // No runtime test is added here because (a) the existing pollers + // are async, multi-minute loops not suited to unit-level invocation + // without elaborate mocking, and (b) an `async fn` signature + // doesn't coerce to a `fn` pointer, so a compile-time signature pin + // would be artificial. The two doc comments + this module-level + // note are the canonical guard. + + // --- BL-P2-085 Step 11c: read-only bypass + AppEvent::CrossProjectBlocked --- + + #[test] + fn test_worker_allows_readonly_without_guard() { + use crate::infra::cross_project_guard::GuardDecision; + + // Read-only (unstamped) actions carry `origin_project_id = None`. The + // worker must let them through without invoking the origin guard, even + // if `RbacGuard.project_id()` is `None` (pre-auth state). + let rbac = RbacGuard::new(); // no project_id set + let dispatched = DispatchedAction::unstamped(Action::FetchServers); + assert_eq!( + check_dispatched_origin(&dispatched, &rbac), + GuardDecision::Allow, + "unstamped actions must skip the origin guard", + ); + + // Even when `RbacGuard` has an active project, an unstamped action + // still bypasses (the guard only fires on stamped envelopes). + let scoped = rbac_with_project("p-active"); + let dispatched_scoped = DispatchedAction::unstamped(Action::FetchServers); + assert_eq!( + check_dispatched_origin(&dispatched_scoped, &scoped), + GuardDecision::Allow, + ); + } + + #[test] + fn test_make_cross_project_blocked_event_carries_reason_and_action() { + use crate::infra::cross_project_guard::CrossProjectReason; + + let action = Action::DeleteServer { + id: "s1".into(), + name: "web".into(), + }; + let reason = CrossProjectReason::OriginScopeMismatch { + origin: "p-stale".into(), + active: "p-active".into(), + }; + let event = make_cross_project_blocked_event(&reason, &action); + match event { + AppEvent::CrossProjectBlocked { + reason: ev_reason, + action: ev_action, + } => { + assert_eq!(ev_reason, "origin_scope_mismatch"); + assert_eq!(ev_action, "DeleteServer"); + } + other => panic!("expected CrossProjectBlocked, got {other:?}"), + } + } + + #[test] + fn test_action_to_kind_switch_context_returns_none() { + // Context switch is orchestration (Keystone rescope), not an RBAC-gated + // mutation. Worker handles it via a different path. + let action = Action::SwitchContext(crate::context::ContextRequest::CloudOnly { + cloud: "devstack".into(), + }); + assert_eq!(action_to_kind(&action), None); + } + #[test] fn test_action_to_kind_resize_actions() { // Resize actions should map to ActionKind::Resize (member-level)