Files
PiCloud/crates/manager-core/src/group_kv_service.rs
MechaCat02 8f35050499 fix(groups): don't let with_events silently downgrade a transactional writer
`with_events()` and `with_atomic_writes()` both install a writer, last call wins.
Appending them in the wrong order in the host would swap the transactional writer
back out for the best-effort one — silently reverting the write-path invariant AND
dropping the per-group quota enforcement that lives in it, with nothing in the type
system to catch it.

The services now carry an `atomic` flag: once `with_atomic_writes` has run,
`with_events` warns and no-ops instead of clobbering it. Also enforce the group
byte ceiling in `BestEffortGroupFilesWriter::update`, so the non-transactional
writer can't be used as an unmetered bypass and a unit test can catch its removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:12:01 +02:00

761 lines
26 KiB
Rust

//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
//!
//! Layers added over the raw repo:
//!
//! 1. Empty-collection rejection.
//! 2. **Owner resolution** (the structural isolation boundary): the collection
//! name is resolved to the nearest ancestor group that declares it shared,
//! walking the calling app's chain. No declaration on the chain → a clean
//! `CollectionNotShared`, BEFORE any authz or storage access.
//! 3. **Reads-open / writes-authed authz**: reads use `script_gate`
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
//! (anonymous fails closed — shared mutation always needs an authenticated
//! editor+ on the owning group).
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group
//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`).
//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true`
//! triggers on the owning group, under the writer app (`emit_shared`).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
GroupId, GroupKvError, GroupKvService, KvListPage, NoopEventEmitter, SdkCallCx,
ServiceEventEmitter,
};
use crate::atomic_write::{
BestEffortGroupKvWriter, GroupKvTarget, GroupKvWriter, PostgresGroupKvWriter,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_kv_repo::{GroupKvRepo, GroupKvRepoError};
use crate::kv_service::kv_max_value_bytes_from_env;
use crate::quota::GroupWriteQuota;
/// The registry `kind` this service resolves.
const KIND_KV: &str = "kv";
pub struct GroupKvServiceImpl {
repo: Arc<dyn GroupKvRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
/// §11.6 per-group quota: max total keys across the group's shared-KV
/// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a
/// NEW key.
max_rows: u64,
/// §11.6 M4 per-group quota: max total stored bytes across the group's
/// shared-KV collections (`PICLOUD_GROUP_KV_MAX_TOTAL_BYTES`). Checked on the
/// projected total (old value subtracted, new added) so a same/smaller update
/// near the cap is still allowed.
max_total_bytes: u64,
/// Mutations: the quota decision, the row write, and the shared-trigger
/// fan-out, which for the host all commit in one advisory-locked
/// transaction. Reads stay on `repo`.
writer: Arc<dyn GroupKvWriter>,
/// Set once [`Self::with_atomic_writes`] has installed the transactional
/// writer. `with_events` refuses to overwrite it — silently downgrading a
/// service back to non-transactional writes (and dropping its quota
/// enforcement) because a builder call was appended in the wrong order is
/// exactly the kind of regression the type system will not catch.
atomic: bool,
}
impl GroupKvServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn GroupKvRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
) -> Self {
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
}
/// Wire the event emitter (§11.6 shared-collection triggers) on the
/// best-effort writer. Without this — or [`Self::with_atomic_writes`], which
/// supersedes it — shared writes fire nothing.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
if self.atomic {
tracing::warn!(
"with_events() called after with_atomic_writes() — ignored; the \
transactional writer already resolves and writes the fan-out itself"
);
return self;
}
self.writer = Arc::new(BestEffortGroupKvWriter::new(self.repo.clone(), events));
self
}
/// Use the transactional writer: the quota reads, the row write, and the
/// shared-trigger fan-out all run on ONE connection under a per-group
/// advisory lock. That makes the quota an actual bound (concurrent writers
/// to a group serialize, so one sees the other's row) and makes an emit
/// failure roll the write back instead of silently losing the event.
///
/// Supersedes [`Self::with_events`] — the transactional writer resolves and
/// writes the fan-out itself and needs no injected emitter.
#[must_use]
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
self.writer = Arc::new(PostgresGroupKvWriter::new(pool));
self.atomic = true;
self
}
/// Override the per-group row quota (§11.6). Mainly for tests; the host uses
/// the env-var default.
#[must_use]
pub fn with_max_rows(mut self, max_rows: u64) -> Self {
self.max_rows = max_rows;
self
}
#[must_use]
pub fn with_max_value_bytes(
repo: Arc<dyn GroupKvRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
) -> Self {
Self {
writer: Arc::new(BestEffortGroupKvWriter::new(
repo.clone(),
Arc::new(NoopEventEmitter),
)),
atomic: false,
max_rows: crate::quota::group_kv_max_rows_from_env(),
max_total_bytes: crate::quota::group_kv_max_total_bytes_from_env(),
repo,
resolver,
authz,
max_value_bytes,
}
}
/// Override the per-group total-bytes quota (§11.6 M4). Mainly for tests.
#[must_use]
pub fn with_max_total_bytes(mut self, max_total_bytes: u64) -> Self {
self.max_total_bytes = max_total_bytes;
self
}
/// The structural boundary: resolve the collection to its owning group on
/// the calling app's chain. `CollectionNotShared` when no ancestor group
/// declares it — the same outcome a foreign app gets, by construction.
async fn owning_group(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<GroupId, GroupKvError> {
if collection.is_empty() {
return Err(GroupKvError::InvalidCollection);
}
self.resolver
.resolve_owning_group(cx.app_id, collection, KIND_KV)
.await
.map_err(|e| GroupKvError::Backend(e.to_string()))?
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
}
/// The ceilings a shared-collection write must fit under.
fn quota(&self) -> GroupWriteQuota {
GroupWriteQuota {
max_rows: self.max_rows,
max_total_bytes: self.max_total_bytes,
max_value_bytes: self.max_value_bytes,
}
}
/// Enforce the per-value byte cap. The per-GROUP byte quota is measured
/// canonically in SQL inside the writing transaction — see
/// `group_quota::check_group_write`.
fn check_value_size(&self, value: &serde_json::Value) -> Result<(), GroupKvError> {
let encoded_len = serde_json::to_vec(value)
.map(|v| v.len())
.map_err(|e| GroupKvError::Backend(format!("encode value: {e}")))?;
if encoded_len > self.max_value_bytes {
return Err(GroupKvError::ValueTooLarge {
limit: self.max_value_bytes,
actual: encoded_len,
});
}
Ok(())
}
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
authz::script_gate(
&*self.authz,
cx,
Capability::GroupKvRead(group_id),
|| GroupKvError::Forbidden,
GroupKvError::Backend,
)
.await
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
// Fails closed for an anonymous principal: shared mutation always needs
// an authenticated editor+ on the owning group.
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupKvWrite(group_id),
|| GroupKvError::Forbidden,
GroupKvError::Backend,
)
.await
}
}
impl From<GroupKvRepoError> for GroupKvError {
fn from(e: GroupKvRepoError) -> Self {
Self::Backend(e.to_string())
}
}
#[async_trait]
impl GroupKvService for GroupKvServiceImpl {
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.get(group_id, collection, key).await?)
}
async fn set(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<(), GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_value_size(&value)?;
self.check_write(cx, group_id).await?;
self.writer
.set(
cx,
GroupKvTarget {
group_id,
collection,
key,
},
value,
self.quota(),
)
.await
}
async fn set_if(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
expected: Option<serde_json::Value>,
new: serde_json::Value,
) -> Result<bool, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_value_size(&new)?;
self.check_write(cx, group_id).await?;
self.writer
.set_if(
cx,
GroupKvTarget {
group_id,
collection,
key,
},
expected,
new,
self.quota(),
)
.await
}
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<bool, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
self.writer
.delete(
cx,
GroupKvTarget {
group_id,
collection,
key,
},
)
.await
}
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.has(group_id, collection, key).await?)
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_read(cx, group_id).await?;
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::{BTreeMap, HashMap};
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemoryGroupKvRepo {
data: Mutex<BTreeMap<(GroupId, String, String), serde_json::Value>>,
}
#[async_trait]
impl GroupKvRepo for InMemoryGroupKvRepo {
async fn get(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
Ok(self
.data
.lock()
.await
.get(&(group_id, collection.to_string(), key.to_string()))
.cloned())
}
async fn set(
&self,
group_id: GroupId,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
Ok(self
.data
.lock()
.await
.insert((group_id, collection.to_string(), key.to_string()), value))
}
async fn set_if(
&self,
group_id: GroupId,
collection: &str,
key: &str,
expected: Option<serde_json::Value>,
new: serde_json::Value,
) -> Result<bool, GroupKvRepoError> {
let mut data = self.data.lock().await;
let k = (group_id, collection.to_string(), key.to_string());
let matches = match (&expected, data.get(&k)) {
(None, None) => true,
(Some(exp), Some(cur)) => exp == cur,
_ => false,
};
if matches {
data.insert(k, new);
}
Ok(matches)
}
async fn delete(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(group_id, collection.to_string(), key.to_string())))
}
async fn has(
&self,
group_id: GroupId,
collection: &str,
key: &str,
) -> Result<bool, GroupKvRepoError> {
Ok(self.data.lock().await.contains_key(&(
group_id,
collection.to_string(),
key.to_string(),
)))
}
async fn list(
&self,
group_id: GroupId,
collection: &str,
_cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvRepoError> {
let data = self.data.lock().await;
let mut keys: Vec<String> = data
.iter()
.filter(|((g, c, _), _)| *g == group_id && c == collection)
.map(|((_, _, k), _)| k.clone())
.collect();
keys.sort();
keys.truncate((limit as usize).max(1));
Ok(KvListPage {
keys,
next_cursor: None,
})
}
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
let data = self.data.lock().await;
Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64)
}
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
let data = self.data.lock().await;
Ok(data
.iter()
.filter(|((g, _, _), _)| *g == group_id)
.map(|(_, v)| serde_json::to_vec(v).map_or(0, |b| b.len() as u64))
.sum())
}
/// Same projection the Postgres impl computes in SQL, but with this
/// mock's own (compact) metric: every OTHER row in the group, plus the
/// new value — i.e. `used - old + new`, measured consistently.
async fn projected_total_bytes(
&self,
group_id: GroupId,
collection: &str,
key: &str,
new_value: &serde_json::Value,
) -> Result<u64, GroupKvRepoError> {
let data = self.data.lock().await;
let target = (group_id, collection.to_string(), key.to_string());
let others: u64 = data
.iter()
.filter(|(k, _)| k.0 == group_id && **k != target)
.map(|(_, v)| serde_json::to_vec(v).map_or(0, |b| b.len() as u64))
.sum();
let new_len = serde_json::to_vec(new_value).map_or(0, |b| b.len() as u64);
Ok(others + new_len)
}
}
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
/// "not shared with you".
#[derive(Default)]
struct FakeResolver {
map: HashMap<(AppId, String), GroupId>,
}
#[async_trait]
impl GroupCollectionResolver for FakeResolver {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
_kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn owner() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
fn member_no_role() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}
}
fn svc(resolver: FakeResolver) -> GroupKvServiceImpl {
GroupKvServiceImpl::new(
Arc::new(InMemoryGroupKvRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
}
#[tokio::test]
async fn per_group_row_quota_rejects_new_keys_but_allows_updates() {
// §11.6: a group's shared-KV store has a row ceiling; a NEW key over the
// cap is rejected, but updating an existing key (net-zero) still works.
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "catalog".into()), group);
let kv = GroupKvServiceImpl::new(
Arc::new(InMemoryGroupKvRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
.with_max_rows(2);
let cx = cx_with(app, Some(owner()));
kv.set(&cx, "catalog", "a", serde_json::json!(1))
.await
.unwrap();
kv.set(&cx, "catalog", "b", serde_json::json!(2))
.await
.unwrap();
// A third distinct key exceeds the cap.
let err = kv
.set(&cx, "catalog", "c", serde_json::json!(3))
.await
.unwrap_err();
assert!(
matches!(
err,
GroupKvError::QuotaExceeded {
limit: 2,
actual: 2
}
),
"got {err:?}"
);
// Updating an existing key is exempt (net-zero).
kv.set(&cx, "catalog", "a", serde_json::json!(11))
.await
.expect("update of an existing key must be allowed at the cap");
}
#[tokio::test]
async fn per_group_byte_quota_uses_the_projected_total() {
// §11.6 M4: the byte ceiling is checked on the PROJECTED total, so a
// write that would push the group over is rejected, but a same/smaller
// update near the cap is still allowed (unlike a naive used+new check).
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "catalog".into()), group);
// A 20-byte ceiling; each `"xxxxx"` value encodes to 7 bytes ("\"xxxxx\"").
let kv = GroupKvServiceImpl::new(
Arc::new(InMemoryGroupKvRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
.with_max_rows(1000)
.with_max_total_bytes(20);
let cx = cx_with(app, Some(owner()));
kv.set(&cx, "catalog", "a", serde_json::json!("xxxxx"))
.await
.unwrap(); // used = 7
kv.set(&cx, "catalog", "b", serde_json::json!("xxxxx"))
.await
.unwrap(); // used = 14
// A third 7-byte value → projected 21 > 20 → rejected.
let err = kv
.set(&cx, "catalog", "c", serde_json::json!("xxxxx"))
.await
.unwrap_err();
assert!(
matches!(err, GroupKvError::TotalBytesQuotaExceeded { limit: 20, .. }),
"got {err:?}"
);
// A same-size update of an existing key stays within budget (projected
// = 14 - 7 + 7 = 14 ≤ 20) — the delta rule, not used+new.
kv.set(&cx, "catalog", "a", serde_json::json!("yyyyy"))
.await
.expect("a same-size update near the cap must be allowed");
// Growing an existing value past the cap is still rejected.
let err = kv
.set(&cx, "catalog", "a", serde_json::json!("zzzzzzzzzzzzzzz"))
.await
.unwrap_err();
assert!(
matches!(err, GroupKvError::TotalBytesQuotaExceeded { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn unrelated_app_gets_collection_not_shared() {
// app_a's chain declares "catalog"; app_b's does not.
let app_a = AppId::new();
let app_b = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app_a, "catalog".into()), group);
let kv = svc(resolver);
// app_a (owner principal) can write+read.
let cx_a = cx_with(app_a, Some(owner()));
kv.set(&cx_a, "catalog", "k", serde_json::json!(1))
.await
.unwrap();
assert_eq!(
kv.get(&cx_a, "catalog", "k").await.unwrap(),
Some(serde_json::json!(1))
);
// app_b is off-chain — the name does not resolve.
let cx_b = cx_with(app_b, Some(owner()));
let err = kv.get(&cx_b, "catalog", "k").await.unwrap_err();
assert!(matches!(err, GroupKvError::CollectionNotShared(c) if c == "catalog"));
}
#[tokio::test]
async fn reads_open_writes_require_auth() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "catalog".into()), group);
let kv = svc(resolver);
// Seed a value as owner.
let owner_cx = cx_with(app, Some(owner()));
kv.set(&owner_cx, "catalog", "k", serde_json::json!("v"))
.await
.unwrap();
// Anonymous (principal=None) READ is allowed (reads-open).
let anon = cx_with(app, None);
assert_eq!(
kv.get(&anon, "catalog", "k").await.unwrap(),
Some(serde_json::json!("v"))
);
// Anonymous WRITE fails closed.
let err = kv
.set(&anon, "catalog", "k", serde_json::json!("x"))
.await
.unwrap_err();
assert!(matches!(err, GroupKvError::Forbidden));
// Authenticated member with no group role: write forbidden.
let member = cx_with(app, Some(member_no_role()));
let err = kv.delete(&member, "catalog", "k").await.unwrap_err();
assert!(matches!(err, GroupKvError::Forbidden));
}
#[tokio::test]
async fn empty_collection_rejected_before_resolve() {
let kv = svc(FakeResolver::default());
let cx = cx_with(AppId::new(), None);
let err = kv.get(&cx, "", "k").await.unwrap_err();
assert!(matches!(err, GroupKvError::InvalidCollection));
}
#[tokio::test]
async fn set_if_is_compare_and_swap_and_writes_fail_closed() {
let app = AppId::new();
let group = GroupId::new();
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "catalog".into()), group);
let kv = svc(resolver);
let cx = cx_with(app, Some(owner()));
// Insert-if-absent, then swap-if-equal (as the per-app path).
assert!(kv
.set_if(&cx, "catalog", "k", None, serde_json::json!(1))
.await
.unwrap());
assert!(!kv
.set_if(&cx, "catalog", "k", None, serde_json::json!(2))
.await
.unwrap());
assert!(!kv
.set_if(
&cx,
"catalog",
"k",
Some(serde_json::json!(9)),
serde_json::json!(3)
)
.await
.unwrap());
assert!(kv
.set_if(
&cx,
"catalog",
"k",
Some(serde_json::json!(1)),
serde_json::json!(3)
)
.await
.unwrap());
assert_eq!(
kv.get(&cx, "catalog", "k").await.unwrap(),
Some(serde_json::json!(3))
);
// A shared-collection CAS still fails closed for an anonymous writer.
let anon = cx_with(app, None);
let err = kv
.set_if(&anon, "catalog", "k", None, serde_json::json!(4))
.await
.unwrap_err();
assert!(matches!(err, GroupKvError::Forbidden));
}
}