use crate::bus::KvStore;
use crate::error::BusError;
use bytes::Bytes;
use std::sync::Arc;
#[derive(Clone)]
pub struct OwnershipRegistry {
kv: Arc<dyn KvStore>,
bucket: String,
strict: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OwnershipCheck {
Allowed,
Denied,
Unavailable,
}
#[non_exhaustive]
pub enum OwnershipClaim {
Claimed(OwnershipHandle),
Proceed,
Unavailable,
}
impl OwnershipRegistry {
pub fn new(kv: Arc<dyn KvStore>, bucket: String) -> Self {
Self {
kv,
bucket,
strict: false,
}
}
pub fn new_strict(kv: Arc<dyn KvStore>, bucket: String) -> Self {
Self {
kv,
bucket,
strict: true,
}
}
pub fn kv(&self) -> &Arc<dyn KvStore> {
&self.kv
}
pub fn bucket(&self) -> &str {
&self.bucket
}
pub fn is_strict(&self) -> bool {
self.strict
}
#[tracing::instrument(
skip_all,
fields(
klieo.tenants.key = %key,
klieo.principal_hash = %crate::principal_hash(&principal),
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "put",
),
err,
)]
pub async fn claim(&self, key: String, principal: String) -> Result<OwnershipHandle, BusError> {
self.kv
.put(&self.bucket, &key, Bytes::from(principal))
.await?;
Ok(OwnershipHandle {
kv: self.kv.clone(),
bucket: self.bucket.clone(),
key,
})
}
pub async fn claim_guarded(&self, key: String, principal: String) -> OwnershipClaim {
match self.claim(key, principal).await {
Ok(handle) => OwnershipClaim::Claimed(handle),
Err(_) if self.strict => {
tracing::warn!(
target: "klieo.tenants",
"ownership claim failed; denying invoke (fail-closed, strict tenant binding)"
);
OwnershipClaim::Unavailable
}
Err(_) => {
tracing::warn!(
target: "klieo.tenants",
"ownership claim failed; proceeding unbound (fail-open per ADR-022)"
);
OwnershipClaim::Proceed
}
}
}
#[tracing::instrument(
skip_all,
fields(
klieo.tenants.key = %key,
klieo.tenants.found = tracing::field::Empty,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "get",
),
err,
level = "debug",
)]
pub async fn lookup(&self, key: &str) -> Result<Option<String>, BusError> {
let result = match self.kv.get(&self.bucket, key).await? {
Some(entry) => {
let s = String::from_utf8(entry.value.to_vec())
.map_err(|e| BusError::Permanent(format!("non-utf8 owner: {e}")))?;
Some(s)
}
None => None,
};
tracing::Span::current().record("klieo.tenants.found", result.is_some());
Ok(result)
}
pub async fn check_owner(&self, key: &str, principal: &str) -> OwnershipCheck {
match self.lookup(key).await {
Ok(Some(owner)) if owner == principal => OwnershipCheck::Allowed,
Ok(Some(_)) => OwnershipCheck::Denied,
Ok(None) => OwnershipCheck::Allowed,
Err(e) if self.strict => {
tracing::warn!(
target: "klieo.tenants",
key = %key,
error = %e,
"ownership lookup failed; denying (fail-closed, strict tenant binding)"
);
OwnershipCheck::Unavailable
}
Err(e) => {
tracing::warn!(
target: "klieo.tenants",
key = %key,
error = %e,
"ownership lookup failed; proceeding (fail-open per ADR-022)"
);
OwnershipCheck::Allowed
}
}
}
}
pub struct OwnershipHandle {
kv: Arc<dyn KvStore>,
bucket: String,
key: String,
}
impl Drop for OwnershipHandle {
fn drop(&mut self) {
let kv = self.kv.clone();
let bucket = std::mem::take(&mut self.bucket);
let key = std::mem::take(&mut self.key);
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
if let Err(e) = kv.delete(&bucket, &key).await {
tracing::warn!(
target: "klieo.tenants",
bucket = %bucket,
key = %key,
error = %e,
"ownership delete failed; entry will linger until bucket TTL",
);
}
});
}
Err(_) => {
tracing::warn!(
target: "klieo.tenants",
bucket = %bucket,
key = %key,
"ownership delete skipped: no tokio runtime active during Drop",
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::fake_kv;
use std::time::Duration;
const BUCKET: &str = "klieo-tenants";
#[tokio::test]
async fn claim_writes_entry_and_lookup_returns_principal() {
let kv = fake_kv();
let reg = OwnershipRegistry::new(kv.clone(), BUCKET.into());
let _handle = reg.claim("a2a.t-1".into(), "alice".into()).await.unwrap();
assert_eq!(
reg.lookup("a2a.t-1").await.unwrap().as_deref(),
Some("alice")
);
}
#[tokio::test]
async fn drop_handle_deletes_entry() {
let kv = fake_kv();
let reg = OwnershipRegistry::new(kv.clone(), BUCKET.into());
{
let _handle = reg.claim("a2a.t-2".into(), "bob".into()).await.unwrap();
assert!(reg.lookup("a2a.t-2").await.unwrap().is_some());
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(reg.lookup("a2a.t-2").await.unwrap().is_none());
}
#[tokio::test]
async fn lookup_returns_none_for_unknown_key() {
let kv = fake_kv();
let reg = OwnershipRegistry::new(kv, BUCKET.into());
assert!(reg.lookup("never").await.unwrap().is_none());
}
struct GetFailsKv;
#[async_trait::async_trait]
impl KvStore for GetFailsKv {
async fn get(&self, _: &str, _: &str) -> Result<Option<crate::bus::KvEntry>, BusError> {
Err(BusError::Connection("kv down".into()))
}
async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<crate::bus::Revision, BusError> {
Err(BusError::Connection("kv down".into()))
}
async fn cas(
&self,
_: &str,
_: &str,
_: Bytes,
_: Option<crate::bus::Revision>,
) -> Result<crate::bus::Revision, BusError> {
Err(BusError::Connection("kv down".into()))
}
async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
Err(BusError::Connection("kv down".into()))
}
async fn lease(
&self,
_: &str,
_: &str,
_: Duration,
) -> Result<crate::bus::Lease, BusError> {
Err(BusError::Connection("kv down".into()))
}
async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
Err(BusError::Connection("kv down".into()))
}
}
#[tokio::test]
async fn check_owner_allows_matching_principal_and_denies_other() {
let kv = fake_kv();
let reg = OwnershipRegistry::new(kv, BUCKET.into());
let _h = reg.claim("a2a.t-c".into(), "alice".into()).await.unwrap();
assert_eq!(
reg.check_owner("a2a.t-c", "alice").await,
OwnershipCheck::Allowed
);
assert_eq!(
reg.check_owner("a2a.t-c", "bob").await,
OwnershipCheck::Denied
);
}
#[tokio::test]
async fn check_owner_allows_unclaimed_key() {
let kv = fake_kv();
let reg = OwnershipRegistry::new(kv, BUCKET.into());
assert_eq!(
reg.check_owner("a2a.legacy", "alice").await,
OwnershipCheck::Allowed
);
}
#[tokio::test]
async fn check_owner_lenient_proceeds_on_store_error() {
let reg = OwnershipRegistry::new(Arc::new(GetFailsKv), BUCKET.into());
assert_eq!(
reg.check_owner("a2a.t", "alice").await,
OwnershipCheck::Allowed,
"a lenient registry fails open on a KV error (ADR-022)"
);
}
#[tokio::test]
async fn claim_guarded_returns_handle_then_policy_on_store_error() {
let ok = OwnershipRegistry::new(fake_kv(), BUCKET.into());
assert!(matches!(
ok.claim_guarded("a2a.t".into(), "alice".into()).await,
OwnershipClaim::Claimed(_)
));
let lenient = OwnershipRegistry::new(Arc::new(GetFailsKv), BUCKET.into());
assert!(
matches!(
lenient.claim_guarded("a2a.t".into(), "alice".into()).await,
OwnershipClaim::Proceed
),
"a lenient registry proceeds unbound when the claim write fails"
);
let strict = OwnershipRegistry::new_strict(Arc::new(GetFailsKv), BUCKET.into());
assert!(
matches!(
strict.claim_guarded("a2a.t".into(), "alice".into()).await,
OwnershipClaim::Unavailable
),
"a strict registry denies (Unavailable) when the claim write fails"
);
}
#[tokio::test]
async fn check_owner_strict_denies_on_store_error() {
let reg = OwnershipRegistry::new_strict(Arc::new(GetFailsKv), BUCKET.into());
assert_eq!(
reg.check_owner("a2a.t", "alice").await,
OwnershipCheck::Unavailable,
"a strict registry fails closed on a KV error rather than risk a cross-tenant resume"
);
}
#[test]
fn is_strict_reflects_constructor_choice() {
let kv = fake_kv();
assert!(
!OwnershipRegistry::new(kv.clone(), BUCKET.into()).is_strict(),
"new() builds a lenient registry"
);
assert!(
OwnershipRegistry::new_strict(kv, BUCKET.into()).is_strict(),
"new_strict() builds a strict registry"
);
}
}