diff --git a/src/adapter/http/glance.rs b/src/adapter/http/glance.rs index c88598c..a68dfb0 100644 --- a/src/adapter/http/glance.rs +++ b/src/adapter/http/glance.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use super::{append_pagination_parts, encode_param, extract_marker_from_url, paginated_list}; use crate::adapter::http::base::BaseHttpClient; +use crate::adapter::http::neutron_audit::GlanceAuditCtx; +use crate::adapter::http::scope_refilter::{RefilterScope, refilter_and_audit}; use crate::models::glance::Image; use crate::port::auth::AuthProvider; use crate::port::error::{ApiError, ApiResult}; @@ -13,6 +15,7 @@ use crate::port::types::*; pub struct GlanceHttpAdapter { base: Arc, + audit_ctx: Option>, } impl GlanceHttpAdapter { @@ -24,11 +27,68 @@ impl GlanceHttpAdapter { EndpointInterface::Public, region, )?), + audit_ctx: None, }) } pub fn from_base(base: Arc) -> Self { - Self { base } + Self { + base, + audit_ctx: None, + } + } + + /// BL-P2-091: attach a `GlanceAuditCtx` so `list_images` runs + /// `refilter_by_scope` against the response and emits an + /// `AdapterFilterViolation` audit event per dropped row. Mirrors the + /// Neutron/Nova/Cinder pattern wired by `registry::new_http`. + 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 (mock registries, integration tests + /// without audit). When attached, partitions via + /// [`refilter_and_audit`] which fans out one `AdapterFilterViolation` + /// event per dropped row before returning the kept items. + /// + /// Glance v2 surfaces `Image.owner` rather than `tenant_id`; the + /// `ScopedItem for Image` impl bridges the two so this helper stays + /// identical to the Neutron/Nova/Cinder shape. + /// + /// [`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 mirrors Neutron: list_* are not bound to a + // worker dispatch, and fingerprint resource_id disambiguates + // per-row events. Epoch propagation lands in a separate cycle. + 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, + } } } @@ -100,7 +160,7 @@ impl GlancePort for GlanceHttpAdapter { pagination: &PaginationParams, ) -> ApiResult> { let query = build_image_query(filter, pagination); - paginated_list( + let resp = paginated_list( &self.base, "/v2/images", &query, @@ -109,7 +169,12 @@ impl GlancePort for GlanceHttpAdapter { (resp.images, next) }, ) - .await + .await?; + // BL-P2-091: defense-in-depth refilter atop server-side visibility + // filtering. Glance v2 doesn't accept `tenant_id=`; we rely + // on `visibility=private/shared/community` plus this response-side + // owner check to fail-safe drop cross-project rows admins may see. + Ok(self.refilter_response(resp, filter.all_tenants, "FetchImages", "image")) } async fn get_image(&self, image_id: &str) -> ApiResult { diff --git a/src/adapter/http/neutron_audit.rs b/src/adapter/http/neutron_audit.rs index ae01cdc..fc931c1 100644 --- a/src/adapter/http/neutron_audit.rs +++ b/src/adapter/http/neutron_audit.rs @@ -73,6 +73,8 @@ pub type NeutronAuditCtx = AuditCtx; pub type NovaAuditCtx = AuditCtx; /// Step 14 placeholder — Cinder adapter wiring will use this alias. pub type CinderAuditCtx = AuditCtx; +/// BL-P2-091 — Glance adapter wiring uses this alias. +pub type GlanceAuditCtx = AuditCtx; /// Bundle of per-service [`AuditCtx`] instances passed to /// [`crate::adapter::registry::AdapterRegistry::new_http`]. Replaces the @@ -84,6 +86,7 @@ pub struct AdapterAuditConfig { pub neutron: Option>, pub nova: Option>, pub cinder: Option>, + pub glance: Option>, } /// Build an [`AdapterAuditConfig`] from the three pieces every service @@ -113,6 +116,7 @@ pub fn build_audit_config( neutron: Some(make("neutron")), nova: Some(make("nova")), cinder: Some(make("cinder")), + glance: Some(make("glance")), } } @@ -347,6 +351,7 @@ mod tests { assert!(cfg.neutron.is_none()); assert!(cfg.nova.is_none()); assert!(cfg.cinder.is_none()); + assert!(cfg.glance.is_none()); } #[test] @@ -356,6 +361,7 @@ mod tests { assert!(cfg.neutron.is_none()); assert!(cfg.nova.is_none()); assert!(cfg.cinder.is_none()); + assert!(cfg.glance.is_none()); } #[test] @@ -378,4 +384,44 @@ mod tests { .expect("cinder ctx must be Some when logger is Some"); assert_eq!(cinder.service(), "cinder"); } + + // --- BL-P2-091: Glance FR1 wiring --- + // `build_audit_config` must also populate `audit.glance` so registry + // `new_http` can pass it to `GlanceHttpAdapter::with_audit`. + + #[test] + fn test_build_audit_config_returns_glance_with_service_glance() { + 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 glance = cfg + .glance + .expect("glance ctx must be Some when logger is Some (BL-P2-091)"); + assert_eq!( + glance.service(), + "glance", + "service discriminator must tag glance" + ); + } + + #[test] + fn test_glance_with_audit_attaches_ctx_default_none() { + // BL-P2-091 mirror of `test_neutron_with_audit_attaches_ctx_default_none`: + // default `GlanceHttpAdapter` leaves `audit_ctx` None; `with_audit` + // returns `Self` so callers chain via `.with_audit(ctx)`. + let dir = TempDir::new().unwrap(); + let ctx = Arc::new(build_ctx(&dir, Some("proj-A"))); + + // Builder type signature alone is enough — the field is private and + // construction needs a BaseHttpClient. Mirror Neutron's signature + // assertion. (Renamed-by-alias: `NeutronAuditCtx` and + // `GlanceAuditCtx` are both `AuditCtx`; `with_audit` is service-typed.) + use crate::adapter::http::glance::GlanceHttpAdapter; + use crate::adapter::http::neutron_audit::GlanceAuditCtx; + let _builder_signature: fn(GlanceHttpAdapter, Arc) -> GlanceHttpAdapter = + GlanceHttpAdapter::with_audit; + let _ = ctx; + } } diff --git a/src/adapter/http/scope_refilter.rs b/src/adapter/http/scope_refilter.rs index c48c193..868287c 100644 --- a/src/adapter/http/scope_refilter.rs +++ b/src/adapter/http/scope_refilter.rs @@ -24,6 +24,7 @@ //! the audit chain depends on every drop being attributable. use crate::models::cinder::{Volume, VolumeSnapshot}; +use crate::models::glance::Image; use crate::models::neutron::{FloatingIp, Network, SecurityGroup}; use crate::models::nova::Server; @@ -41,6 +42,24 @@ pub trait ScopedItem { /// 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>; + /// BL-P2-091: short-circuit keep for rows whose access model is + /// governed by something other than `tenant_id` equality. The canonical + /// case is Glance: `visibility = public/community/shared` images are + /// intentionally cross-project, and their `owner` is often a different + /// project (or absent) — owner-equality refilter would drop them and + /// break the standard non-admin `list_images` flow. + /// + /// Default `false` preserves Neutron/Nova/Cinder behaviour (tenant_id + /// equality is the only authoritative scope test). Image overrides + /// to keep `visibility != "private"` rows regardless of `owner`. + /// + /// Globally-accessible rows bypass refilter entirely; they neither + /// land in `kept` nor `dropped` from the filtering perspective — they + /// are returned unchanged to the caller and never emit an + /// AdapterFilterViolation event (no leak signal). + fn is_globally_accessible(&self) -> bool { + false + } } /// Encodes the (active, all_tenants) invariant for [`refilter_by_scope`] in @@ -170,6 +189,15 @@ pub fn refilter_by_scope( let mut kept = Vec::with_capacity(items.len()); let mut dropped = Vec::new(); for item in items { + // BL-P2-091: globally-accessible rows (Glance public/community/ + // shared) bypass the owner check — their visibility marker is + // the authoritative access decision, and their `tenant_id` is + // routinely a different project (or absent). Drop would break + // standard non-admin list_images for everyone. + if item.is_globally_accessible() { + kept.push(item); + continue; + } match item.tenant_id() { Some(tid) if tid == active => kept.push(item), _ => dropped.push(item), @@ -275,6 +303,38 @@ impl ScopedItem for VolumeSnapshot { } } +// --- BL-P2-091: ScopedItem impl for Glance Image --- +// Glance is the asymmetric branch. `owner` is the project-id equivalent of +// `tenant_id` on Neutron/Nova/Cinder, but Glance's visibility model +// (`public` / `private` / `shared` / `community`) means the OWNER check +// only applies to `private` rows. Public/community/shared images are +// intentionally cross-project — their visibility marker IS the access +// decision, and refusing them would break the standard non-admin +// `list_images` flow (no public Ubuntu image, etc). +// +// `is_globally_accessible()` therefore short-circuits the refilter for any +// non-private visibility. The FR1 leak signal we still catch: +// `visibility == "private"` AND `owner != active` +// — a private image of another project surfaced into the response. + +impl ScopedItem for Image { + fn tenant_id(&self) -> Option<&str> { + self.owner.as_deref() + } + fn resource_id(&self) -> Option<&str> { + Some(&self.id) + } + fn is_globally_accessible(&self) -> bool { + // Glance v2 visibility is one of `public`, `private`, `community`, + // `shared`. The three non-private values are governed by + // visibility ACLs server-side and must be passed through; positive + // allowlist (not `!= "private"`) so any unrecognized value + // fail-safes to the owner refilter rather than silently bypassing + // it when a new variant ships. + matches!(self.visibility.as_str(), "public" | "community" | "shared") + } +} + #[cfg(test)] mod tests { use super::*; @@ -809,4 +869,132 @@ mod tests { assert_eq!(snap.tenant_id(), None); assert_eq!(snap.resource_id(), Some("snap-2")); } + + // --- BL-P2-091: ScopedItem impl for Glance Image --- + // `Image.owner: Option` maps to `tenant_id()` (Glance v2 uses + // `owner` rather than `tenant_id` on the wire). `Image.id: String` + // always present → `resource_id()`. + + fn sample_image(id: &str, owner: Option<&str>) -> crate::models::glance::Image { + crate::models::glance::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_image_has_scoped_item_returns_some_when_present() { + let img = sample_image("img-1", Some("proj-A")); + assert_eq!(img.tenant_id(), Some("proj-A")); + assert_eq!(img.resource_id(), Some("img-1")); + } + + #[test] + fn test_image_has_scoped_item_returns_none_when_absent() { + // Private image without an `owner` field — under strict scoping the + // refilter drops it fail-safe (no proof of ownership). + let img = sample_image("img-2", None); + assert_eq!(img.tenant_id(), None); + assert_eq!(img.resource_id(), Some("img-2")); + assert!( + !img.is_globally_accessible(), + "default sample_image is private; owner check applies" + ); + } + + // --- BL-P2-091 Codex P1 fix: visibility-aware short-circuit --- + // Glance visibility marker IS the access decision for public/community/ + // shared images. Refilter must let them through regardless of `owner`, + // or non-admin users lose access to the standard image catalog (Ubuntu + // public images, distro AMIs, etc.). + + fn sample_image_with_visibility( + id: &str, + owner: Option<&str>, + visibility: &str, + ) -> crate::models::glance::Image { + let mut img = sample_image(id, owner); + img.visibility = visibility.to_string(); + img + } + + #[test] + fn test_image_is_globally_accessible_when_visibility_public() { + let img = sample_image_with_visibility("img-pub", Some("other-proj"), "public"); + assert!( + img.is_globally_accessible(), + "public images must bypass owner refilter" + ); + } + + #[test] + fn test_image_is_globally_accessible_when_visibility_community() { + let img = sample_image_with_visibility("img-com", Some("other-proj"), "community"); + assert!(img.is_globally_accessible()); + } + + #[test] + fn test_image_is_globally_accessible_when_visibility_shared() { + let img = sample_image_with_visibility("img-sh", Some("other-proj"), "shared"); + assert!(img.is_globally_accessible()); + } + + #[test] + fn test_image_is_not_globally_accessible_when_visibility_private() { + let img = sample_image_with_visibility("img-priv", Some("other-proj"), "private"); + assert!( + !img.is_globally_accessible(), + "private images must apply owner refilter" + ); + } + + #[test] + fn test_image_unknown_visibility_treated_as_private_fail_safe() { + // Unknown visibility values must NOT relax the refilter — future + // Glance versions adding a new variant shouldn't silently bypass + // the owner check. + let img = sample_image_with_visibility("img-?", Some("other-proj"), "unobtainium"); + assert!( + !img.is_globally_accessible(), + "unknown visibility must fail-safe to owner refilter" + ); + } + + #[test] + fn test_refilter_keeps_public_image_even_when_cross_project() { + // Codex P1 regression: a non-admin user listing images under + // strict scope must still see public/community/shared images that + // happen to be owned by a different project. + let items = vec![ + sample_image_with_visibility("pub-1", Some("admin-proj"), "public"), + sample_image_with_visibility("priv-mine", Some("proj-A"), "private"), + sample_image_with_visibility("priv-other", Some("proj-B"), "private"), + ]; + let (kept, dropped) = refilter_by_scope(items, &RefilterScope::strict("proj-A")); + let kept_ids: Vec<&str> = kept.iter().map(|i| i.id.as_str()).collect(); + let dropped_ids: Vec<&str> = dropped.iter().map(|i| i.id.as_str()).collect(); + assert!( + kept_ids.contains(&"pub-1"), + "public image must survive refilter (kept_ids={kept_ids:?})" + ); + assert!( + kept_ids.contains(&"priv-mine"), + "private image owned by active project must be kept" + ); + assert_eq!( + dropped_ids, + vec!["priv-other"], + "only private cross-project images must be dropped" + ); + } } diff --git a/src/adapter/registry.rs b/src/adapter/registry.rs index 8819c06..d0e6af6 100644 --- a/src/adapter/registry.rs +++ b/src/adapter/registry.rs @@ -82,12 +82,16 @@ impl AdapterRegistry { if let Some(ctx) = audit.cinder { cinder = cinder.with_audit(ctx); } + let mut glance = GlanceHttpAdapter::from_base(glance_base); + if let Some(ctx) = audit.glance { + glance = glance.with_audit(ctx); + } Ok(Self { nova: Arc::new(nova), neutron: Arc::new(neutron), cinder: Arc::new(cinder), - glance: Arc::new(GlanceHttpAdapter::from_base(glance_base)), + glance: Arc::new(glance), keystone: Arc::new(KeystoneHttpAdapter::from_base(keystone_base)), http_caches, })