diff --git a/client-sdk/go/modules/roflmarket/types.go b/client-sdk/go/modules/roflmarket/types.go index 044e55c62f..a2ff9d3962 100644 --- a/client-sdk/go/modules/roflmarket/types.go +++ b/client-sdk/go/modules/roflmarket/types.go @@ -124,6 +124,25 @@ type Offer struct { Metadata map[string]string `json:"metadata"` } +// Well-known offer metadata keys recognized by the ROFL scheduler. +const ( + // MetadataKeyOffer is the offer metadata key holding the offer identifier (the value used by + // the scheduler's local config to select which offers it serves). + MetadataKeyOffer = "net.oasis.scheduler.offer" + // MetadataKeyOfferAllowedCreators is the offer metadata key holding a comma-separated list of + // allowed instance creator addresses. When unset or empty, all creators are allowed. + MetadataKeyOfferAllowedCreators = "net.oasis.scheduler.offer.allowed_creators" + // MetadataKeyOfferAllowedArtifactsPrefix is the prefix of the offer metadata keys holding the + // allowed artifact hashes. The artifact kind is the suffix following the prefix (e.g. + // "net.oasis.scheduler.offer.allowed_artifacts.firmware") and the value is a comma-separated + // list of allowed SHA256 hashes. If no key exists for a kind, all artifacts of that kind are + // allowed. + MetadataKeyOfferAllowedArtifactsPrefix = "net.oasis.scheduler.offer.allowed_artifacts." + // MetadataKeyOfferPrivate is the offer metadata key marking an offer as private. Private + // offers are hidden from normal offer listings unless explicitly requested. + MetadataKeyOfferPrivate = "net.oasis.scheduler.offer.private" +) + // Term is the reservation term. type Term uint8 diff --git a/rofl-scheduler/src/config.rs b/rofl-scheduler/src/config.rs index 6df855f403..bf0c6581f5 100644 --- a/rofl-scheduler/src/config.rs +++ b/rofl-scheduler/src/config.rs @@ -1,17 +1,11 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; +use std::{collections::BTreeSet, sync::Arc}; use anyhow::{anyhow, Result}; use base64::prelude::*; use oasis_runtime_sdk::{ cbor, - core::{ - common::crypto::{hash::Hash, signature::PublicKey}, - Protocol, - }, + core::{common::crypto::signature::PublicKey, Protocol}, types::address::Address, }; use oasis_runtime_sdk_rofl_market as market; @@ -19,102 +13,20 @@ use oasis_runtime_sdk_rofl_market as market; /// Local configuration key that contains the ROFL scheduler configuration. const ROFL_SCHEDULER_CONFIG_KEY: &str = "rofl_scheduler"; -/// Raw per-offer configuration as serialized. -/// -/// Each element of the `offers` sequence is either a plain string (old format, no overrides) or -/// a map with an `id` field and optional per-offer config overrides: -/// -/// ```yaml -/// offers: -/// - public # plain string — global defaults apply -/// - id: internal # map entry — per-offer overrides -/// allowed_creators: -/// - "oasis1..." -/// ``` -#[derive(Clone, Debug, Default)] -pub struct RawOfferConfig { - /// Offer identifier (value of the `net.oasis.scheduler.offer` metadata key). - pub id: String, - /// Allowed instance creator addresses for this offer. - /// - /// When set, overrides the global `allowed_creators`. An empty list means all creators are - /// allowed. When not set (`None`), the global `allowed_creators` is used as a fallback. - pub allowed_creators: Option>, - /// Allowed artifact hashes for this offer. - /// - /// When set, overrides the global `allowed_artifacts` entirely. When not set (`None`), the - /// global `allowed_artifacts` is used as a fallback. - pub allowed_artifacts: Option>>, -} - -impl cbor::Decode for RawOfferConfig { - fn try_default() -> Result { - Ok(Self::default()) - } - - fn try_from_cbor_value(value: cbor::Value) -> Result { - match value { - // Plain string: the offer name with no overrides (global defaults apply). - cbor::Value::TextString(id) => Ok(Self { - id, - allowed_creators: None, - allowed_artifacts: None, - }), - // Map with an `id` field and optional overrides. - cbor::Value::Map(entries) => { - let mut id = None; - let mut allowed_creators = None; - let mut allowed_artifacts = None; - for (k, v) in entries { - let cbor::Value::TextString(key) = k else { - return Err(cbor::DecodeError::UnexpectedType); - }; - match key.as_str() { - "id" => { - id = Some(::try_from_cbor_value(v)?); - } - "allowed_creators" => { - allowed_creators = - Some( as cbor::Decode>::try_from_cbor_value(v)?); - } - "allowed_artifacts" => { - allowed_artifacts = Some( - > as cbor::Decode>::try_from_cbor_value(v)?, - ); - } - _ => {} - } - } - Ok(Self { - id: id.ok_or(cbor::DecodeError::MissingField)?, - allowed_creators, - allowed_artifacts, - }) - } - _ => Err(cbor::DecodeError::UnexpectedType), - } - } -} - /// Raw local configuration as serialized. +/// +/// Unknown fields are ignored so that configs still carrying the removed `allowed_creators` and +/// `allowed_artifacts` keys (now sourced from on-chain offer metadata) continue to load. #[derive(Clone, Debug, Default, cbor::Decode)] +#[cbor(allow_unknown)] pub struct RawLocalConfig { /// Address of the provider. pub provider_address: String, /// Offers that the scheduler should accept. If no offers are configured, all are accepted. /// - /// Each entry is either a plain string (legacy form, no overrides) or a map with an `id` - /// field and optional per-offer config overrides. - pub offers: Vec, - /// Allowed artifact hashes. - /// - /// Key is the artifact kind and value is a list of artifact SHA256 hashes. If a key doesn't - /// exist, all artifacts are allowed. - pub allowed_artifacts: BTreeMap>, - /// Allowed instance creator addresses. - /// - /// If empty, any address is allowed. - pub allowed_creators: Vec, + /// Each entry is the value of the `net.oasis.scheduler.offer` metadata key. Per-offer access + /// policy (allowed creators and artifacts) lives in the on-chain offer metadata, not here. + pub offers: Vec, /// Resource capacity. pub capacity: Resources, /// Internal on which the scheduler will do its processing (in seconds). @@ -213,35 +125,17 @@ impl Resources { } } -/// Per-offer configuration. -#[derive(Clone, Debug, Default)] -pub struct OfferConfig { - /// Allowed instance creator addresses. - /// - /// When `Some`, overrides the global `allowed_creators`. An empty set means all creators are - /// allowed. When `None`, the global `allowed_creators` is used as a fallback. - pub allowed_creators: Option>, - /// Allowed artifact hashes. - /// - /// When `Some`, overrides the global `allowed_artifacts` entirely. When `None`, the global - /// `allowed_artifacts` is used as a fallback. - pub allowed_artifacts: Option>>, -} - /// Local scheduler configuration. #[derive(Clone, Debug, Default)] pub struct LocalConfig { /// Address of the provider. pub provider_address: Address, - /// Offers that the scheduler should accept, with optional per-offer configuration. + /// Offers that the scheduler should accept. /// - /// Each key is the value of the `net.oasis.scheduler.offer` metadata key. If the map is - /// empty, all offers are accepted using the global defaults. - pub offers: BTreeMap, - /// Allowed artifact hashes (global default, may be overridden per offer). - pub allowed_artifacts: BTreeMap>, - /// Allowed instance creator addresses (global default, may be overridden per offer). - pub allowed_creators: BTreeSet
, + /// Each entry is the value of the `net.oasis.scheduler.offer` metadata key. If the set is + /// empty, all offers are accepted. Per-offer access policy (allowed creators and artifacts) + /// lives in the on-chain offer metadata. + pub offers: BTreeSet, /// Resource capacity. pub capacity: Resources, /// Internal on which the scheduler will do its processing (in seconds). @@ -271,69 +165,8 @@ impl LocalConfig { pub fn from_raw(cfg: RawLocalConfig) -> Result { let provider_address = Address::from_bech32(&cfg.provider_address) .map_err(|_| anyhow!("bad provider address"))?; - let allowed_artifacts = cfg - .allowed_artifacts - .into_iter() - .map(|(kind, hashes)| -> Result<(String, BTreeSet)> { - Ok(( - kind, - hashes - .into_iter() - .map(|h| -> Result { Ok(h.parse::()?) }) - .collect::>>()?, - )) - }) - .collect::>() - .map_err(|_| anyhow!("bad allowed artifacts value"))?; - let allowed_creators = cfg - .allowed_creators - .into_iter() - .map(|raw| Address::from_bech32(&raw)) - .collect::, _>>() - .map_err(|_| anyhow!("bad allowed creators value"))?; - let offers = cfg - .offers - .into_iter() - .map(|raw_cfg| -> Result<(String, OfferConfig)> { - let key = raw_cfg.id; - let offer_creators = raw_cfg - .allowed_creators - .map(|creators| { - creators - .into_iter() - .map(|raw| Address::from_bech32(&raw)) - .collect::, _>>() - .map_err(|_| anyhow!("bad allowed_creators in offer '{key}'")) - }) - .transpose()?; - let offer_artifacts = raw_cfg - .allowed_artifacts - .map(|artifacts| { - artifacts - .into_iter() - .map(|(kind, hashes)| -> Result<(String, BTreeSet)> { - Ok(( - kind, - hashes - .into_iter() - .map(|h| -> Result { Ok(h.parse::()?) }) - .collect::>>()?, - )) - }) - .collect::>() - .map_err(|_| anyhow!("bad allowed_artifacts in offer '{key}'")) - }) - .transpose()?; - Ok(( - key, - OfferConfig { - allowed_creators: offer_creators, - allowed_artifacts: offer_artifacts, - }, - )) - }) - .collect::>()?; + let offers = cfg.offers.into_iter().collect(); let transfer_instances_from = cfg .transfer_instances_from @@ -351,8 +184,6 @@ impl LocalConfig { Ok(LocalConfig { provider_address, offers, - allowed_artifacts, - allowed_creators, capacity: cfg.capacity, processing_interval_secs: cfg.processing_interval.unwrap_or(3), claim_payment_interval_secs: cfg.claim_payment_interval.unwrap_or(24) * 3600, @@ -367,130 +198,8 @@ impl LocalConfig { }) } - /// Validate the given artifact hash against the set of allowed artifacts. - /// - /// Uses the per-offer artifact list when the offer has one configured; otherwise falls back - /// to the global `allowed_artifacts`. - pub fn ensure_artifact_allowed(&self, offer_key: &str, kind: &str, hash: &Hash) -> Result<()> { - let artifacts = self - .offers - .get(offer_key) - .and_then(|cfg| cfg.allowed_artifacts.as_ref()) - .unwrap_or(&self.allowed_artifacts); - - match artifacts.get(kind) { - None => Ok(()), // All artifacts of this kind are allowed. - Some(allowed_hashes) => { - if !allowed_hashes.contains(hash) { - Err(anyhow!("{kind} artifact not allowed")) - } else { - Ok(()) - } - } - } - } - - /// Check whether the given creator is among the allowed creators for the given offer. - /// - /// Uses the per-offer creator list when the offer has one configured; otherwise falls back to - /// the global `allowed_creators`. An empty list means all creators are allowed. - pub fn is_creator_allowed(&self, offer_key: &str, address: &Address) -> bool { - if let Some(offer_cfg) = self.offers.get(offer_key) { - if let Some(ref creators) = offer_cfg.allowed_creators { - return creators.is_empty() || creators.contains(address); - } - } - self.allowed_creators.is_empty() || self.allowed_creators.contains(address) - } - /// Check whether the given node identifier is among the list of nodes to transfer instances from. pub fn should_transfer_instance_from(&self, node_id: &PublicKey) -> bool { self.transfer_instances_from.contains(node_id) } } - -#[cfg(test)] -mod test { - use oasis_runtime_sdk::testing; - - use super::*; - - fn decode_offers(cbor: cbor::Value) -> Vec { - as cbor::Decode>::try_from_cbor_value(cbor).unwrap() - } - - #[test] - fn test_offers_legacy_array_format() { - // Old format: plain array of strings. Each becomes a RawOfferConfig with no overrides. - let offers = decode_offers(cbor::Value::Array(vec![ - cbor::Value::TextString("small".into()), - cbor::Value::TextString("large".into()), - ])); - assert_eq!(offers.len(), 2); - assert_eq!(offers[0].id, "small"); - assert!(offers[0].allowed_creators.is_none()); - assert_eq!(offers[1].id, "large"); - } - - #[test] - fn test_offers_mixed_format() { - // Mixed: plain strings alongside a map entry with an explicit `id` field and overrides. - let offers = decode_offers(cbor::Value::Array(vec![ - cbor::Value::TextString("small".into()), - cbor::Value::TextString("large".into()), - cbor::Value::Map(vec![ - ( - cbor::Value::TextString("id".into()), - cbor::Value::TextString("internal".into()), - ), - ( - cbor::Value::TextString("allowed_creators".into()), - cbor::Value::Array(vec![cbor::Value::TextString( - "oasis1qp0cnmkjl22gky6p7q0tgkwmsc6g4c5er6x0hsk7".into(), - )]), - ), - ]), - ])); - assert_eq!(offers[0].id, "small"); - assert!(offers[0].allowed_creators.is_none()); - assert_eq!(offers[1].id, "large"); - assert!(offers[1].allowed_creators.is_none()); - assert_eq!(offers[2].id, "internal"); - assert!(offers[2].allowed_creators.is_some()); - } - - #[test] - fn test_is_creator_allowed_fallback() { - // Per-offer None → global fallback. - let cfg = LocalConfig { - offers: BTreeMap::from([("public".into(), OfferConfig::default())]), - allowed_creators: BTreeSet::new(), // global: allow all - ..Default::default() - }; - let addr = testing::keys::alice::address(); - assert!(cfg.is_creator_allowed("public", &addr)); - } - - #[test] - fn test_is_creator_allowed_per_offer_override() { - let allowed = testing::keys::alice::address(); - let blocked = testing::keys::bob::address(); - - let cfg = LocalConfig { - offers: BTreeMap::from([( - "internal".into(), - OfferConfig { - allowed_creators: Some(BTreeSet::from([allowed])), - allowed_artifacts: None, - }, - )]), - allowed_creators: BTreeSet::new(), // global: allow all (should not apply here) - ..Default::default() - }; - - assert!(cfg.is_creator_allowed("internal", &allowed)); - assert!(!cfg.is_creator_allowed("internal", &blocked)); - // Unknown offer key → global fallback (allow all). - assert!(cfg.is_creator_allowed("public", &blocked)); - } -} diff --git a/rofl-scheduler/src/manager.rs b/rofl-scheduler/src/manager.rs index 85d677b403..377c21199f 100644 --- a/rofl-scheduler/src/manager.rs +++ b/rofl-scheduler/src/manager.rs @@ -10,13 +10,16 @@ use backoff::backoff::Backoff; use base64::{prelude::BASE64_STANDARD, Engine}; use bytes::BufMut; use futures_util::StreamExt; -use oasis_runtime_sdk::core::{ - common::{ - crypto::{hash::Hash, signature::PublicKey}, - logger::get_logger, - process, +use oasis_runtime_sdk::{ + core::{ + common::{ + crypto::{hash::Hash, signature::PublicKey}, + logger::get_logger, + process, + }, + host::{bundle_manager, volume_manager}, }, - host::{bundle_manager, volume_manager}, + types::address::Address, }; use oasis_runtime_sdk_rofl_market::{ self as market, @@ -45,6 +48,18 @@ use crate::{ /// Metadata key used to configure the offer identifier. const METADATA_KEY_OFFER: &str = "net.oasis.scheduler.offer"; +/// Offer metadata key with the comma-separated list of allowed instance creator addresses. When +/// unset or empty, all creators are allowed. +const METADATA_KEY_OFFER_ALLOWED_CREATORS: &str = "net.oasis.scheduler.offer.allowed_creators"; +/// Prefix of the offer metadata keys with the allowed artifact hashes. The artifact kind is the +/// suffix following the prefix (e.g. `...allowed_artifacts.firmware`) and the value is a +/// comma-separated list of allowed SHA256 hashes. If no key exists for a kind, all artifacts of +/// that kind are allowed. +const METADATA_KEY_OFFER_ALLOWED_ARTIFACTS_PREFIX: &str = + "net.oasis.scheduler.offer.allowed_artifacts."; +/// Offer metadata key marking an offer as private. Private offers are hidden from normal offer +/// listings; the scheduler reads but does not act on this flag. +const METADATA_KEY_OFFER_PRIVATE: &str = "net.oasis.scheduler.offer.private"; /// Metadata key used to configure the deployment ORC bundle location. const METADATA_KEY_DEPLOYMENT_ORC_REF: &str = "net.oasis.deployment.orc.ref"; /// Metadata key used to report errors. @@ -102,6 +117,93 @@ impl InstanceUpdates { } } +/// Per-offer access policy parsed from on-chain offer metadata. +#[derive(Clone, Debug, Default)] +struct OfferPolicy { + /// Allowed instance creator addresses. An empty set means all creators are allowed. + allowed_creators: BTreeSet
, + /// Allowed artifact hashes keyed by artifact kind. When a kind is absent, all artifacts of + /// that kind are allowed. + allowed_artifacts: BTreeMap>, + /// Whether the offer is marked private. The scheduler reads but does not act on this flag; it + /// only affects how offers are listed. + private: bool, +} + +impl OfferPolicy { + /// Parse the offer access policy from on-chain offer metadata. Malformed list entries are + /// silently skipped. + fn from_metadata(metadata: &BTreeMap) -> Self { + let allowed_creators = metadata + .get(METADATA_KEY_OFFER_ALLOWED_CREATORS) + .map(|raw| { + parse_csv(raw) + .filter_map(|item| Address::from_bech32(item).ok()) + .collect() + }) + .unwrap_or_default(); + + let mut allowed_artifacts: BTreeMap> = BTreeMap::new(); + for (key, raw) in metadata { + let Some(kind) = key.strip_prefix(METADATA_KEY_OFFER_ALLOWED_ARTIFACTS_PREFIX) else { + continue; + }; + if kind.is_empty() { + continue; + } + let hashes = parse_csv(raw) + .filter_map(|item| item.parse::().ok()) + .collect(); + allowed_artifacts.insert(kind.to_string(), hashes); + } + + let private = metadata + .get(METADATA_KEY_OFFER_PRIVATE) + .map(|v| is_truthy(v)) + .unwrap_or(false); + + Self { + allowed_creators, + allowed_artifacts, + private, + } + } + + /// Whether the given creator is allowed by this offer. An empty list means all creators are + /// allowed. + fn is_creator_allowed(&self, address: &Address) -> bool { + self.allowed_creators.is_empty() || self.allowed_creators.contains(address) + } + + /// Validate the given artifact hash against this offer's allowed artifacts. When the offer + /// does not restrict the given kind, all artifacts of that kind are allowed. + fn ensure_artifact_allowed(&self, kind: &str, hash: &Hash) -> Result<()> { + match self.allowed_artifacts.get(kind) { + None => Ok(()), // All artifacts of this kind are allowed. + Some(allowed_hashes) => { + if !allowed_hashes.contains(hash) { + Err(anyhow!("{kind} artifact not allowed")) + } else { + Ok(()) + } + } + } + } +} + +/// Split a comma-separated metadata value into trimmed, non-empty items. +fn parse_csv(raw: &str) -> impl Iterator { + raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) +} + +/// Whether a metadata flag value is truthy. +fn is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "1" | "yes" + ) +} + struct LocalState { /// Market query client instance for a specific round. client: Arc, @@ -111,9 +213,11 @@ struct LocalState { running: BTreeMap, /// Map from on-chain offer ID to its metadata key (`net.oasis.scheduler.offer` value). offer_key_by_id: BTreeMap, + /// Map from on-chain offer ID to its parsed access policy (allowed creators/apps, private). + offer_policy_by_id: BTreeMap, /// A list of deployments that should already be running but are not. - /// Tuple: (instance, deployment, wipe_storage, offer_key). - pending_start: Vec<(Instance, Deployment, bool, String)>, + /// Tuple: (instance, deployment, wipe_storage, offer_policy). + pending_start: Vec<(Instance, Deployment, bool, OfferPolicy)>, /// A list of instance identifiers that should have no running deployments. pending_stop: Vec<(InstanceId, bool)>, /// A map of instance updates. @@ -255,6 +359,7 @@ impl Manager { accepted: BTreeMap::new(), running: BTreeMap::new(), offer_key_by_id: BTreeMap::new(), + offer_policy_by_id: BTreeMap::new(), pending_start: Vec::new(), pending_stop: Vec::new(), instance_updates: BTreeMap::new(), @@ -308,16 +413,20 @@ impl Manager { let mut running_unknown = BTreeSet::from_iter(local_state.running.keys().copied().chain(volumes)); - // Build a map from offer ID to offer metadata key, used when populating pending_start so - // that per-offer config (allowed_creators, allowed_artifacts) can be applied later. + // Build maps from offer ID to its metadata key and to its parsed on-chain access policy + // (allowed creators/apps, private flag). These are used later when evaluating instances. let offers = local_state.client.offers().await?; - local_state.offer_key_by_id = offers - .into_iter() - .filter_map(|offer| { - let key = offer.metadata.get(METADATA_KEY_OFFER)?.clone(); - Some((offer.id, key)) - }) - .collect(); + for offer in offers { + let policy = OfferPolicy::from_metadata(&offer.metadata); + if policy.private { + slog::debug!(self.logger, "discovered private offer"; "offer" => ?offer.id); + } + local_state.offer_policy_by_id.insert(offer.id, policy); + + if let Some(key) = offer.metadata.get(METADATA_KEY_OFFER) { + local_state.offer_key_by_id.insert(offer.id, key.clone()); + } + } // Discover desired instance state. let instances: Vec = local_state.client.instances().await?; @@ -489,8 +598,8 @@ impl Manager { if actual_hash != desired_hash || force_restart { // Note that any old instances will be restarted in case they are already // running and we add them to `pending_start`. - let offer_key = local_state - .offer_key_by_id + let offer_policy = local_state + .offer_policy_by_id .get(&instance.offer) .cloned() .unwrap_or_default(); @@ -498,20 +607,20 @@ impl Manager { instance, desired, wipe_storage, - offer_key, + offer_policy, )); } } (None, Some(desired)) => { // Instance is not running and should be started. - let offer_key = local_state - .offer_key_by_id + let offer_policy = local_state + .offer_policy_by_id .get(&instance.offer) .cloned() .unwrap_or_default(); local_state .pending_start - .push((instance, desired, wipe_storage, offer_key)); + .push((instance, desired, wipe_storage, offer_policy)); } (Some(_), None) => { // Instance is running and should be stopped. @@ -563,15 +672,13 @@ impl Manager { ) -> Result<()> { let instances = local_state.client.instances().await?; - // Build a map from offer ID to offer key for offers this scheduler accepts. - // `offer_key_by_id` was populated by discover(); no second network call needed. - let acceptable_offers: BTreeMap = local_state + // Build the set of offer IDs this scheduler accepts. `offer_key_by_id` was populated by + // discover(); no second network call needed. + let acceptable_offers: BTreeSet = local_state .offer_key_by_id .iter() - .filter(|(_, key)| { - self.cfg.offers.is_empty() || self.cfg.offers.contains_key(key.as_str()) - }) - .map(|(id, key)| (*id, key.clone())) + .filter(|(_, key)| self.cfg.offers.is_empty() || self.cfg.offers.contains(key.as_str())) + .map(|(id, _)| *id) .collect(); for instance in instances { @@ -611,23 +718,27 @@ impl Manager { "transfer" => transfer_instance, ); - // Check if offer is among the configured offers (and retrieve its key for per-offer - // policy lookups that follow). - let offer_key = match acceptable_offers.get(&instance.offer) { - Some(key) => key.as_str(), - None => { - slog::info!(self.logger, "offer not acceptable for this instance"; - "id" => ?instance.id, - "offer" => ?instance.offer, - "transfer" => transfer_instance, - ); - maybe_remove(); - continue; - } - }; + // Check if offer is among the configured offers. + if !acceptable_offers.contains(&instance.offer) { + slog::info!(self.logger, "offer not acceptable for this instance"; + "id" => ?instance.id, + "offer" => ?instance.offer, + "transfer" => transfer_instance, + ); + maybe_remove(); + continue; + } + + // Look up the offer's on-chain access policy (allowed creators/apps). A missing entry + // means the offer has no policy set, so global defaults apply. + let offer_policy = local_state + .offer_policy_by_id + .get(&instance.offer) + .cloned() + .unwrap_or_default(); - // Check if creator is among the allowed creators for this offer (falls back to global). - if !self.cfg.is_creator_allowed(offer_key, &instance.creator) { + // Check if creator is among the allowed creators for this offer. + if !offer_policy.is_creator_allowed(&instance.creator) { slog::info!(self.logger, "creator not allowed"; "id" => ?instance.id, "creator" => instance.creator, @@ -693,13 +804,13 @@ impl Manager { let start_jobs: Vec<_> = local_state .pending_start .iter() - .map(|(instance, deployment, wipe_storage, offer_key)| { + .map(|(instance, deployment, wipe_storage, offer_policy)| { self.clone().start_instance( local_state.client.clone(), instance.clone(), deployment.clone(), *wipe_storage, - offer_key.clone(), + offer_policy.clone(), ) }) .collect(); @@ -891,7 +1002,7 @@ impl Manager { instance: Instance, deployment: Deployment, wipe_storage: bool, - offer_key: String, + offer_policy: OfferPolicy, ) -> Result<()> { if !self.should_start_instance(instance.id, &deployment) { return Ok(()); @@ -906,7 +1017,7 @@ impl Manager { self.set_last_instance_deployment(instance.id, &deployment); match self - .pull_and_deploy_instance(client, &instance, &deployment, &offer_key) + .pull_and_deploy_instance(client, &instance, &deployment, &offer_policy) .await { Ok(_) => { @@ -996,10 +1107,10 @@ impl Manager { client: Arc, instance: &Instance, deployment: &Deployment, - offer_key: &str, + offer_policy: &OfferPolicy, ) -> Result<()> { let deployment_info = self - .pull_and_validate_deployment(instance, deployment, offer_key) + .pull_and_validate_deployment(instance, deployment, offer_policy) .await?; self.deploy_instance(client, instance, deployment, deployment_info) .await @@ -1134,7 +1245,7 @@ impl Manager { self: &Arc, instance: &Instance, deployment: &Deployment, - offer_key: &str, + offer_policy: &OfferPolicy, ) -> Result { slog::info!(self.logger, "pulling deployment bundle"; "instance_id" => ?instance.id, @@ -1246,32 +1357,28 @@ impl Manager { .digests .get(&tdx.firmware) .ok_or(anyhow!("ORC is missing firmware digest"))?; - self.cfg - .ensure_artifact_allowed(offer_key, "firmware", firmware_digest)?; + offer_policy.ensure_artifact_allowed("firmware", firmware_digest)?; if !tdx.kernel.is_empty() { let kernel_digest = orc_manifest .digests .get(&tdx.kernel) .ok_or(anyhow!("ORC is missing kernel digest"))?; - self.cfg - .ensure_artifact_allowed(offer_key, "kernel", kernel_digest)?; + offer_policy.ensure_artifact_allowed("kernel", kernel_digest)?; if !tdx.initrd.is_empty() { let initrd_digest = orc_manifest .digests .get(&tdx.initrd) .ok_or(anyhow!("ORC is missing initrd digest"))?; - self.cfg - .ensure_artifact_allowed(offer_key, "initrd", initrd_digest)?; + offer_policy.ensure_artifact_allowed("initrd", initrd_digest)?; } if !tdx.stage2_image.is_empty() { let stage2_digest = orc_manifest .digests .get(&tdx.stage2_image) .ok_or(anyhow!("ORC is missing stage2 digest"))?; - self.cfg - .ensure_artifact_allowed(offer_key, "stage2", stage2_digest)?; + offer_policy.ensure_artifact_allowed("stage2", stage2_digest)?; qcow2_names.insert(tdx.stage2_image.clone()); @@ -1599,3 +1706,92 @@ fn deployment_hash_for_scheduler(scheduler_id: &[u8; 32], deployment: &Deploymen format!("{hash:x}") } + +#[cfg(test)] +mod test { + use oasis_runtime_sdk::testing; + + use super::*; + + #[test] + fn test_offer_policy_absent() { + let policy = OfferPolicy::from_metadata(&BTreeMap::new()); + assert!(policy.allowed_creators.is_empty()); + assert!(policy.allowed_artifacts.is_empty()); + assert!(!policy.private); + // Empty policy allows all creators. + assert!(policy.is_creator_allowed(&testing::keys::alice::address())); + } + + #[test] + fn test_offer_policy_allowed_creators() { + let alice = testing::keys::alice::address(); + let bob = testing::keys::bob::address(); + let metadata = BTreeMap::from([( + METADATA_KEY_OFFER_ALLOWED_CREATORS.to_string(), + // Whitespace is trimmed; the malformed entry is skipped. + format!(" {} , garbage ", alice.to_bech32()), + )]); + + let policy = OfferPolicy::from_metadata(&metadata); + assert_eq!(policy.allowed_creators.len(), 1); + assert!(policy.allowed_creators.contains(&alice)); + + assert!(policy.is_creator_allowed(&alice)); + assert!(!policy.is_creator_allowed(&bob)); + } + + #[test] + fn test_offer_policy_empty_creators_allows_all() { + // Key present but empty → all creators allowed. + let alice = testing::keys::alice::address(); + let metadata = BTreeMap::from([( + METADATA_KEY_OFFER_ALLOWED_CREATORS.to_string(), + "".to_string(), + )]); + let policy = OfferPolicy::from_metadata(&metadata); + assert!(policy.is_creator_allowed(&alice)); + } + + #[test] + fn test_offer_policy_allowed_artifacts() { + let allowed = Hash::digest_bytes(b"allowed-firmware"); + let blocked = Hash::digest_bytes(b"blocked-firmware"); + let kernel = Hash::digest_bytes(b"some-kernel"); + let metadata = BTreeMap::from([( + format!("{METADATA_KEY_OFFER_ALLOWED_ARTIFACTS_PREFIX}firmware"), + format!(" {allowed:x} , garbage "), + )]); + + let policy = OfferPolicy::from_metadata(&metadata); + // Only the valid firmware hash is parsed; the malformed entry is skipped. + assert_eq!(policy.allowed_artifacts["firmware"].len(), 1); + assert!(policy.ensure_artifact_allowed("firmware", &allowed).is_ok()); + assert!(policy + .ensure_artifact_allowed("firmware", &blocked) + .is_err()); + // Kinds without a configured key are unrestricted. + assert!(policy.ensure_artifact_allowed("kernel", &kernel).is_ok()); + } + + #[test] + fn test_offer_policy_artifacts_unset_allows_all() { + let policy = OfferPolicy::default(); + let hash = Hash::digest_bytes(b"anything"); + assert!(policy.ensure_artifact_allowed("firmware", &hash).is_ok()); + } + + #[test] + fn test_offer_policy_private() { + for v in ["true", "1", "YES", "True"] { + let metadata = + BTreeMap::from([(METADATA_KEY_OFFER_PRIVATE.to_string(), v.to_string())]); + assert!(OfferPolicy::from_metadata(&metadata).private, "{v}"); + } + for v in ["false", "0", "", "no"] { + let metadata = + BTreeMap::from([(METADATA_KEY_OFFER_PRIVATE.to_string(), v.to_string())]); + assert!(!OfferPolicy::from_metadata(&metadata).private, "{v}"); + } + } +}