use std::marker::PhantomData;
use std::sync::Arc;
use uuid::Uuid;
use crate::label::Label;
use crate::name::ResourceName;
use crate::object::{Association, Object};
use crate::registry::{FieldRole, ResourceRegistry};
use crate::store::{
AssociationStore, AssociationStoreReader, EdgeEndpoint, EdgeQuery, ObjectStore,
ObjectStoreReader, Precondition, SecretObjectReader, StoreExec, StoreTx, Transactional,
};
use crate::{Error, Result};
pub struct ManagedObjectStore<L: Label, S> {
inner: S,
#[cfg(feature = "encryption")]
encryptor: Option<crate::encryption::EnvelopeEncryptor>,
registry: Arc<ResourceRegistry<L>>,
_label: PhantomData<L>,
}
impl<L: Label, S: Clone> Clone for ManagedObjectStore<L, S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
#[cfg(feature = "encryption")]
encryptor: self.encryptor.clone(),
registry: Arc::clone(&self.registry),
_label: PhantomData,
}
}
}
impl<L: Label, S: ObjectStore<L>> ManagedObjectStore<L, S> {
pub fn new(inner: S, registry: ResourceRegistry<L>) -> Self {
Self {
inner,
#[cfg(feature = "encryption")]
encryptor: None,
registry: Arc::new(registry),
_label: PhantomData,
}
}
#[cfg(feature = "encryption")]
pub fn with_encryptor(
inner: S,
encryptor: crate::encryption::EnvelopeEncryptor,
registry: ResourceRegistry<L>,
) -> Self {
Self {
inner,
encryptor: Some(encryptor),
registry: Arc::new(registry),
_label: PhantomData,
}
}
}
impl<L: Label, S> ManagedObjectStore<L, S> {
fn strip_fields(
&self,
label: L,
properties: Option<serde_json::Value>,
) -> (
Option<serde_json::Value>,
Option<serde_json::Map<String, serde_json::Value>>,
) {
let Some(serde_json::Value::Object(mut map)) = properties else {
return (properties, None);
};
let Some(descriptor) = self.registry.get(label) else {
return (Some(serde_json::Value::Object(map)), None);
};
let mut sensitive_map = serde_json::Map::new();
for field in descriptor.fields.iter() {
match field.role {
FieldRole::Identifier | FieldRole::Managed => {
map.remove(field.name);
}
FieldRole::Sensitive => {
if let Some(value) = map.remove(field.name) {
sensitive_map.insert(field.name.to_string(), value);
}
}
FieldRole::Data => {
}
}
}
let sensitive = if sensitive_map.is_empty() {
None
} else {
Some(sensitive_map)
};
(Some(serde_json::Value::Object(map)), sensitive)
}
fn inject_fields(&self, object: &mut Object<L>) {
let Some(descriptor) = self.registry.get(object.label) else {
return;
};
let map = object
.properties
.get_or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let Some(map) = map.as_object_mut() else {
return;
};
for field in descriptor.fields.iter() {
match field.role {
FieldRole::Identifier => {
map.insert(
field.name.to_string(),
serde_json::Value::String(object.id.to_string()),
);
}
FieldRole::Managed => {
match field.name {
"created_at" => {
map.insert(
field.name.to_string(),
serde_json::Value::Number(
object.created_at.timestamp_millis().into(),
),
);
}
"updated_at" => {
if let Some(updated) = object.updated_at {
map.insert(
field.name.to_string(),
serde_json::Value::Number(updated.timestamp_millis().into()),
);
}
}
_ => {
}
}
}
FieldRole::Sensitive => {
map.remove(field.name);
}
FieldRole::Data => {
}
}
}
}
async fn resolve_sensitive_blob(
&self,
id: &Uuid,
label: L,
sensitive: Option<serde_json::Map<String, serde_json::Value>>,
) -> Result<Option<bytes::Bytes>> {
let Some(map) = sensitive else {
return Ok(None);
};
#[cfg(feature = "encryption")]
if let Some(encryptor) = self.encryptor.as_ref() {
let plaintext = serde_json::to_vec(&serde_json::Value::Object(map))?;
let blob = encryptor.seal(&secret_name(id), &plaintext).await?;
return Ok(Some(bytes::Bytes::from(blob)));
}
let _ = (id, &map);
Err(Error::invalid_argument(format!(
"resource '{label}' has sensitive field(s) but the store has no encryptor configured \
to seal them; build the store with `ManagedObjectStore::with_encryptor` (requires the \
`encryption` feature)"
)))
}
}
#[cfg(feature = "encryption")]
fn secret_name(id: &Uuid) -> String {
id.hyphenated().to_string()
}
#[async_trait::async_trait]
impl<L: Label, S: ObjectStoreReader<L>> ObjectStoreReader<L> for ManagedObjectStore<L, S> {
async fn get(&self, id: &Uuid) -> Result<Object<L>> {
let mut object = self.inner.get(id).await?;
self.inject_fields(&mut object);
Ok(object)
}
async fn get_by_name(&self, label: L, name: &ResourceName) -> Result<Object<L>> {
let mut object = self.inner.get_by_name(label, name).await?;
self.inject_fields(&mut object);
Ok(object)
}
async fn list(
&self,
label: L,
namespace: Option<&ResourceName>,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
let (mut objects, token) = self
.inner
.list(label, namespace, max_results, page_token)
.await?;
for object in &mut objects {
self.inject_fields(object);
}
Ok((objects, token))
}
async fn get_sensitive(&self, id: &Uuid) -> Result<Option<bytes::Bytes>> {
self.inner.get_sensitive(id).await
}
}
#[async_trait::async_trait]
impl<L: Label, S: ObjectStore<L>> ObjectStore<L> for ManagedObjectStore<L, S> {
async fn create(
&self,
label: L,
name: &ResourceName,
properties: Option<serde_json::Value>,
id: Option<Uuid>,
sensitive: Option<bytes::Bytes>,
) -> Result<Object<L>> {
let (stripped, sensitive_fields) = self.strip_fields(label, properties);
let id = id.unwrap_or_else(Uuid::now_v7);
let blob = self
.resolve_sensitive_blob(&id, label, sensitive_fields)
.await?
.or(sensitive);
let mut object = self
.inner
.create(label, name, stripped, Some(id), blob)
.await?;
self.inject_fields(&mut object);
Ok(object)
}
async fn update(
&self,
id: &Uuid,
properties: Option<serde_json::Value>,
precondition: Precondition,
sensitive: Option<bytes::Bytes>,
) -> Result<Object<L>> {
let existing = self.inner.get(id).await?;
let (stripped, sensitive_fields) = self.strip_fields(existing.label, properties);
let blob = self
.resolve_sensitive_blob(id, existing.label, sensitive_fields)
.await?
.or(sensitive);
let mut object = self.inner.update(id, stripped, precondition, blob).await?;
self.inject_fields(&mut object);
Ok(object)
}
async fn rename(
&self,
id: &Uuid,
new_name: &ResourceName,
precondition: Precondition,
) -> Result<Object<L>> {
let mut object = self.inner.rename(id, new_name, precondition).await?;
self.inject_fields(&mut object);
Ok(object)
}
async fn delete(&self, id: &Uuid) -> Result<()> {
self.inner.delete(id).await
}
async fn set_sensitive(&self, id: &Uuid, sensitive: bytes::Bytes) -> Result<()> {
self.inner.set_sensitive(id, sensitive).await
}
}
#[async_trait::async_trait]
impl<L: Label, S: AssociationStoreReader<L>> AssociationStoreReader<L>
for ManagedObjectStore<L, S>
{
async fn query_edges(
&self,
query: EdgeQuery<'_, L>,
) -> Result<(Vec<Association<L>>, Option<String>)> {
self.inner.query_edges(query).await
}
async fn count_edges(
&self,
endpoint: EdgeEndpoint,
label: &str,
target_label: Option<L>,
) -> Result<u64> {
self.inner.count_edges(endpoint, label, target_label).await
}
}
#[async_trait::async_trait]
impl<L: Label, S: AssociationStore<L>> AssociationStore<L> for ManagedObjectStore<L, S> {
async fn add(
&self,
from_id: Uuid,
to_id: Uuid,
label: &str,
properties: Option<serde_json::Value>,
) -> Result<()> {
self.inner.add(from_id, to_id, label, properties).await
}
async fn remove(&self, from_id: Uuid, to_id: Uuid, label: &str) -> Result<()> {
self.inner.remove(from_id, to_id, label).await
}
}
#[async_trait::async_trait]
impl<L: Label, S: ObjectStore<L>> SecretObjectReader<L> for ManagedObjectStore<L, S> {
#[tracing::instrument(
skip_all,
fields(
id = %id,
otel.name = "olai_store.get_with_secrets",
otel.kind = "client",
)
)]
async fn get_with_secrets(&self, id: &Uuid) -> Result<Object<L>> {
let mut object = self.inner.get(id).await?;
self.inject_fields(&mut object);
#[cfg(feature = "encryption")]
if let Some(encryptor) = self.encryptor.as_ref()
&& let Some(blob) = self.inner.get_sensitive(id).await?
{
let name = secret_name(id);
let plaintext = encryptor.open(&name, &blob).await?;
let sensitive: serde_json::Value = serde_json::from_slice(&plaintext)?;
if let (Some(props), serde_json::Value::Object(secret_map)) =
(object.properties.as_mut(), sensitive)
&& let Some(props_map) = props.as_object_mut()
{
for (key, value) in secret_map {
props_map.insert(key, value);
}
}
match encryptor.rewrap(&blob).await {
Ok(Some(rewrapped)) => {
tracing::debug!(id = %id, "rewrapping sensitive blob under active KEK");
if let Err(e) = self
.inner
.set_sensitive(id, bytes::Bytes::from(rewrapped))
.await
{
tracing::warn!(
id = %id,
error.type = e.kind_str(),
"KEK rotation writeback failed; blob remains under retired KEK"
);
}
}
Ok(None) => {}
Err(e) => tracing::debug!(
id = %id,
error.type = e.kind_str(),
"rewrap check failed; leaving blob unchanged"
),
}
}
Ok(object)
}
}
#[async_trait::async_trait]
impl<L: Label, S: Transactional<L>> Transactional<L> for ManagedObjectStore<L, S> {
async fn transaction<'a, T>(
&'a self,
f: Box<
dyn for<'t> FnOnce(&'t dyn StoreExec<L>) -> futures::future::BoxFuture<'t, Result<T>>
+ Send
+ 'a,
>,
) -> Result<T>
where
T: Send + 'a,
{
self.inner.transaction(f).await
}
async fn begin(&self) -> Result<Box<dyn StoreTx<L>>> {
self.inner.begin().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::InMemoryStore;
use crate::registry::{ResourceFieldDescriptor, ResourceTypeDescriptor};
use std::str::FromStr;
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TestLabel {
Widget,
Other,
}
impl std::fmt::Display for TestLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for TestLabel {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"widget" => Ok(TestLabel::Widget),
"other" => Ok(TestLabel::Other),
_ => Err(Error::invalid_argument(format!("unknown label: {s}"))),
}
}
}
impl Label for TestLabel {
fn as_str(&self) -> &str {
match self {
TestLabel::Widget => "widget",
TestLabel::Other => "other",
}
}
}
static WIDGET_FIELDS: &[ResourceFieldDescriptor] = &[
ResourceFieldDescriptor {
name: "id",
role: FieldRole::Identifier,
},
ResourceFieldDescriptor {
name: "created_at",
role: FieldRole::Managed,
},
ResourceFieldDescriptor {
name: "updated_at",
role: FieldRole::Managed,
},
ResourceFieldDescriptor {
name: "color",
role: FieldRole::Data,
},
ResourceFieldDescriptor {
name: "api_key",
role: FieldRole::Sensitive,
},
];
static OTHER_FIELDS: &[ResourceFieldDescriptor] = &[ResourceFieldDescriptor {
name: "value",
role: FieldRole::Data,
}];
static DESCRIPTORS: &[ResourceTypeDescriptor<TestLabel>] = &[
ResourceTypeDescriptor {
label: TestLabel::Widget,
fields: WIDGET_FIELDS,
path_names: &["name"],
parent_label: None,
},
ResourceTypeDescriptor {
label: TestLabel::Other,
fields: OTHER_FIELDS,
path_names: &["name"],
parent_label: None,
},
];
fn registry() -> ResourceRegistry<TestLabel> {
ResourceRegistry::from_static(DESCRIPTORS)
}
fn rn(s: &str) -> ResourceName {
ResourceName::from_naive_str_split(s)
}
fn props(json: serde_json::Value) -> Option<serde_json::Value> {
Some(json)
}
#[cfg(feature = "encryption")]
fn encryptor() -> crate::encryption::EnvelopeEncryptor {
crate::encryption::EnvelopeEncryptor::local(
crate::encryption::LocalKeyProvider::dev_insecure(),
)
}
#[test]
fn registry_lookups() {
let reg = registry();
assert!(reg.get(TestLabel::Widget).is_some());
assert!(reg.has_sensitive_fields(TestLabel::Widget));
assert!(!reg.has_sensitive_fields(TestLabel::Other));
assert_eq!(
reg.sensitive_field_names(TestLabel::Widget),
vec!["api_key"]
);
assert_eq!(reg.identifier_field_name(TestLabel::Widget), Some("id"));
assert_eq!(reg.identifier_field_name(TestLabel::Other), None);
let mut managed = reg.managed_field_names(TestLabel::Widget);
managed.sort_unstable();
assert_eq!(managed, vec!["created_at", "updated_at"]);
assert_eq!(reg.parent_label(TestLabel::Widget), None);
assert_eq!(reg.path_names(TestLabel::Widget), Some(&["name"][..]));
}
#[tokio::test]
async fn strips_managed_and_identifier_on_create_and_injects_on_read() {
let store = ManagedObjectStore::new(InMemoryStore::<TestLabel>::new(), registry());
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({
"id": "client-supplied-id",
"created_at": "client-supplied-time",
"color": "red",
})),
None,
None,
)
.await
.unwrap();
let map = created.properties.as_ref().unwrap().as_object().unwrap();
assert_eq!(map["color"], serde_json::json!("red"));
assert_eq!(map["id"], serde_json::json!(created.id.to_string()));
assert_eq!(
map["created_at"],
serde_json::json!(created.created_at.timestamp_millis())
);
let raw = store.inner.get(&created.id).await.unwrap();
let raw_map = raw.properties.as_ref().unwrap().as_object().unwrap();
assert!(!raw_map.contains_key("id"));
assert!(!raw_map.contains_key("created_at"));
assert_eq!(raw_map["color"], serde_json::json!("red"));
}
#[tokio::test]
async fn association_ops_pass_through_to_inner() {
use crate::store::{AssociationStore, AssociationStoreReader, EdgeQuery};
let store = ManagedObjectStore::new(InMemoryStore::<TestLabel>::new(), registry());
let a = store
.create(TestLabel::Widget, &rn("a"), None, None, None)
.await
.unwrap();
let b = store
.create(TestLabel::Other, &rn("b"), None, None, None)
.await
.unwrap();
store.add(a.id, b.id, "links", None).await.unwrap();
let (edges, token) = store
.query_edges(EdgeQuery::from(a.id, "links"))
.await
.unwrap();
assert_eq!(token, None);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].to_id, b.id);
assert_eq!(edges[0].to_label, TestLabel::Other);
store.remove(a.id, b.id, "links").await.unwrap();
let (edges, _) = store
.query_edges(EdgeQuery::from(a.id, "links"))
.await
.unwrap();
assert!(edges.is_empty());
}
#[tokio::test]
async fn transaction_passes_through_to_inner_and_commits() {
let store = ManagedObjectStore::new(InMemoryStore::<TestLabel>::new(), registry());
let (a_id, b_id) = store
.transaction(Box::new(|tx: &dyn StoreExec<TestLabel>| {
Box::pin(async move {
let a = tx
.create(TestLabel::Other, &rn("a"), None, None, None)
.await?;
let b = tx
.create(TestLabel::Other, &rn("b"), None, None, None)
.await?;
Ok((a.id, b.id))
})
}))
.await
.unwrap();
assert_eq!(store.get(&a_id).await.unwrap().id, a_id);
assert_eq!(store.get(&b_id).await.unwrap().id, b_id);
}
#[tokio::test]
async fn writing_sensitive_field_without_encryptor_errors() {
let store = ManagedObjectStore::new(InMemoryStore::<TestLabel>::new(), registry());
let err = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "color": "blue", "api_key": "supersecret" })),
None,
None,
)
.await
.unwrap_err();
assert!(
matches!(err, Error::InvalidArgument(_)),
"expected InvalidArgument, got {err:?}"
);
assert!(
store
.get_by_name(TestLabel::Widget, &rn("w1"))
.await
.is_err()
);
}
#[tokio::test]
async fn write_without_sensitive_fields_succeeds_without_encryptor() {
let store = ManagedObjectStore::new(InMemoryStore::<TestLabel>::new(), registry());
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "color": "blue" })),
None,
None,
)
.await
.unwrap();
let map = created.properties.as_ref().unwrap().as_object().unwrap();
assert_eq!(map["color"], serde_json::json!("blue"));
assert!(!map.contains_key("api_key"));
store
.create(
TestLabel::Other,
&rn("o1"),
props(serde_json::json!({ "value": "v" })),
None,
None,
)
.await
.unwrap();
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn sensitive_value_sealed_and_redacted_on_get() {
let store = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
encryptor(),
registry(),
);
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "color": "green", "api_key": "topsecret" })),
None,
None,
)
.await
.unwrap();
let map = created.properties.as_ref().unwrap().as_object().unwrap();
assert!(!map.contains_key("api_key"));
assert_eq!(map["color"], serde_json::json!("green"));
let blob = store
.inner
.get_sensitive(&created.id)
.await
.unwrap()
.expect("blob stored");
assert!(!blob.windows(b"topsecret".len()).any(|w| w == b"topsecret"));
let got = store.get(&created.id).await.unwrap();
assert!(
!got.properties
.as_ref()
.unwrap()
.as_object()
.unwrap()
.contains_key("api_key")
);
let full = store.get_with_secrets(&created.id).await.unwrap();
let full_map = full.properties.as_ref().unwrap().as_object().unwrap();
assert_eq!(full_map["api_key"], serde_json::json!("topsecret"));
assert_eq!(full_map["color"], serde_json::json!("green"));
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn update_seals_new_sensitive_value() {
let store = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
encryptor(),
registry(),
);
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "color": "green" })),
None,
None,
)
.await
.unwrap();
assert!(
store
.inner
.get_sensitive(&created.id)
.await
.unwrap()
.is_none()
);
store
.update(
&created.id,
props(serde_json::json!({ "color": "green", "api_key": "k1" })),
Precondition::Any,
None,
)
.await
.unwrap();
let full = store.get_with_secrets(&created.id).await.unwrap();
assert_eq!(
full.properties.as_ref().unwrap().as_object().unwrap()["api_key"],
serde_json::json!("k1")
);
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn update_overwrites_existing_secret() {
let store = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
encryptor(),
registry(),
);
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "api_key": "old" })),
None,
None,
)
.await
.unwrap();
store
.update(
&created.id,
props(serde_json::json!({ "api_key": "new" })),
Precondition::Any,
None,
)
.await
.unwrap();
let full = store.get_with_secrets(&created.id).await.unwrap();
assert_eq!(
full.properties.as_ref().unwrap().as_object().unwrap()["api_key"],
serde_json::json!("new")
);
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn update_without_sensitive_fields_preserves_blob() {
let store = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
encryptor(),
registry(),
);
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "color": "green", "api_key": "keep-me" })),
None,
None,
)
.await
.unwrap();
store
.update(
&created.id,
props(serde_json::json!({ "color": "blue" })),
Precondition::Any,
None,
)
.await
.unwrap();
let full = store.get_with_secrets(&created.id).await.unwrap();
let full_map = full.properties.as_ref().unwrap().as_object().unwrap();
assert_eq!(full_map["api_key"], serde_json::json!("keep-me"));
assert_eq!(full_map["color"], serde_json::json!("blue"));
}
#[cfg(feature = "encryption")]
#[tokio::test]
async fn delete_removes_sealed_blob() {
let store = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
encryptor(),
registry(),
);
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "api_key": "s" })),
None,
None,
)
.await
.unwrap();
assert!(
store
.inner
.get_sensitive(&created.id)
.await
.unwrap()
.is_some()
);
store.delete(&created.id).await.unwrap();
assert!(matches!(
store.get(&created.id).await.unwrap_err(),
Error::NotFound
));
assert!(
store
.inner
.get_sensitive(&created.id)
.await
.unwrap()
.is_none()
);
}
#[cfg(feature = "encryption")]
#[tokio::test]
#[tracing_test::traced_test]
async fn rotation_rewraps_on_read() {
use crate::encryption::{EnvelopeEncryptor, LocalKeyProvider};
let k1 = vec![1u8; 32];
let k2 = vec![2u8; 32];
let store_v1 = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
EnvelopeEncryptor::local(LocalKeyProvider::single("v1", k1.clone()).unwrap()),
registry(),
);
let created = store_v1
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "api_key": "rotate-me" })),
None,
None,
)
.await
.unwrap();
let inner = store_v1.inner;
let store_v2 = ManagedObjectStore::with_encryptor(
inner,
EnvelopeEncryptor::local(
LocalKeyProvider::new("v2", [("v1".into(), k1), ("v2".into(), k2.clone())])
.unwrap(),
),
registry(),
);
let full = store_v2.get_with_secrets(&created.id).await.unwrap();
assert_eq!(
full.properties.as_ref().unwrap().as_object().unwrap()["api_key"],
serde_json::json!("rotate-me")
);
assert!(logs_contain("rewrapping sensitive blob"));
let raw = store_v2.inner.get(&created.id).await.unwrap();
assert!(
raw.properties
.as_ref()
.and_then(|p| p.as_object())
.is_none_or(|m| !m.contains_key("api_key")),
"rotation writeback leaked plaintext into stored properties"
);
assert_eq!(
raw.version, created.version,
"rotation writeback must not bump the object version"
);
let redacted = store_v2.get(&created.id).await.unwrap();
assert!(
!redacted
.properties
.as_ref()
.unwrap()
.as_object()
.unwrap()
.contains_key("api_key")
);
let store_v2_only = ManagedObjectStore::with_encryptor(
store_v2.inner,
EnvelopeEncryptor::local(LocalKeyProvider::single("v2", k2).unwrap()),
registry(),
);
let full = store_v2_only.get_with_secrets(&created.id).await.unwrap();
assert_eq!(
full.properties.as_ref().unwrap().as_object().unwrap()["api_key"],
serde_json::json!("rotate-me")
);
}
#[cfg(feature = "encryption")]
struct FailWriteback<S>(S);
#[cfg(feature = "encryption")]
#[async_trait::async_trait]
impl<L: Label, S: ObjectStoreReader<L>> ObjectStoreReader<L> for FailWriteback<S> {
async fn get(&self, id: &Uuid) -> Result<Object<L>> {
self.0.get(id).await
}
async fn get_by_name(&self, label: L, name: &ResourceName) -> Result<Object<L>> {
self.0.get_by_name(label, name).await
}
async fn list(
&self,
label: L,
namespace: Option<&ResourceName>,
max_results: Option<usize>,
page_token: Option<String>,
) -> Result<(Vec<Object<L>>, Option<String>)> {
self.0.list(label, namespace, max_results, page_token).await
}
async fn get_sensitive(&self, id: &Uuid) -> Result<Option<bytes::Bytes>> {
self.0.get_sensitive(id).await
}
}
#[cfg(feature = "encryption")]
#[async_trait::async_trait]
impl<L: Label, S: ObjectStore<L>> ObjectStore<L> for FailWriteback<S> {
async fn create(
&self,
label: L,
name: &ResourceName,
properties: Option<serde_json::Value>,
id: Option<Uuid>,
sensitive: Option<bytes::Bytes>,
) -> Result<Object<L>> {
self.0.create(label, name, properties, id, sensitive).await
}
async fn update(
&self,
id: &Uuid,
properties: Option<serde_json::Value>,
precondition: Precondition,
sensitive: Option<bytes::Bytes>,
) -> Result<Object<L>> {
self.0.update(id, properties, precondition, sensitive).await
}
async fn rename(
&self,
id: &Uuid,
new_name: &ResourceName,
precondition: Precondition,
) -> Result<Object<L>> {
self.0.rename(id, new_name, precondition).await
}
async fn delete(&self, id: &Uuid) -> Result<()> {
self.0.delete(id).await
}
async fn set_sensitive(&self, _id: &Uuid, _sensitive: bytes::Bytes) -> Result<()> {
Err(Error::generic("boom"))
}
}
#[cfg(feature = "encryption")]
#[tokio::test]
#[tracing_test::traced_test]
async fn writeback_failure_warns_but_read_succeeds() {
use crate::encryption::{EnvelopeEncryptor, LocalKeyProvider};
let k1 = vec![1u8; 32];
let k2 = vec![2u8; 32];
let store_v1 = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
EnvelopeEncryptor::local(LocalKeyProvider::single("v1", k1.clone()).unwrap()),
registry(),
);
let created = store_v1
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "api_key": "rotate-me" })),
None,
None,
)
.await
.unwrap();
let store_v2 = ManagedObjectStore::with_encryptor(
FailWriteback(store_v1.inner),
EnvelopeEncryptor::local(
LocalKeyProvider::new("v2", [("v1".into(), k1), ("v2".into(), k2)]).unwrap(),
),
registry(),
);
let full = store_v2.get_with_secrets(&created.id).await.unwrap();
assert_eq!(
full.properties.as_ref().unwrap().as_object().unwrap()["api_key"],
serde_json::json!("rotate-me")
);
assert!(logs_contain("KEK rotation writeback failed"));
}
#[cfg(feature = "encryption")]
#[tokio::test]
#[tracing_test::traced_test]
async fn get_with_secrets_span_never_leaks_plaintext() {
let store = ManagedObjectStore::with_encryptor(
InMemoryStore::<TestLabel>::new(),
encryptor(),
registry(),
);
let created = store
.create(
TestLabel::Widget,
&rn("w1"),
props(serde_json::json!({ "color": "SECRET_SENTINEL_VALUE", "api_key": "SECRET_PLAINTEXT" })),
None,
None,
)
.await
.unwrap();
let full = store.get_with_secrets(&created.id).await.unwrap();
assert_eq!(
full.properties.as_ref().unwrap().as_object().unwrap()["api_key"],
serde_json::json!("SECRET_PLAINTEXT")
);
logs_assert(|lines: &[&str]| {
for line in lines {
if line.contains("SECRET_SENTINEL_VALUE") || line.contains("SECRET_PLAINTEXT") {
return Err(format!("secret/payload leaked into tracing output: {line}"));
}
}
Ok(())
});
}
}