//! `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`). //! //! No event emission in the MVP: a write to a shared collection has no single //! app to attribute a trigger to (the "group trigger has no app to watch" //! problem). Documented as a deferral in docs §11.6. use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{GroupId, GroupKvError, GroupKvService, KvListPage, SdkCallCx}; 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; /// The registry `kind` this service resolves. const KIND_KV: &str = "kv"; pub struct GroupKvServiceImpl { repo: Arc, resolver: Arc, authz: Arc, max_value_bytes: usize, } impl GroupKvServiceImpl { #[must_use] pub fn new( repo: Arc, resolver: Arc, authz: Arc, ) -> Self { Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env()) } #[must_use] pub fn with_max_value_bytes( repo: Arc, resolver: Arc, authz: Arc, max_value_bytes: usize, ) -> Self { Self { repo, resolver, authz, max_value_bytes, } } /// 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 { 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())) } 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 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, 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?; 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, }); } self.check_write(cx, group_id).await?; self.repo.set(group_id, collection, key, value).await?; Ok(()) } async fn delete( &self, cx: &SdkCallCx, collection: &str, key: &str, ) -> Result { let group_id = self.owning_group(cx, collection).await?; self.check_write(cx, group_id).await?; Ok(self.repo.delete(group_id, collection, key).await?.is_some()) } async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result { 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 { 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>, } #[async_trait] impl GroupKvRepo for InMemoryGroupKvRepo { async fn get( &self, group_id: GroupId, collection: &str, key: &str, ) -> Result, 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, GroupKvRepoError> { Ok(self .data .lock() .await .insert((group_id, collection.to_string(), key.to_string()), value)) } async fn delete( &self, group_id: GroupId, collection: &str, key: &str, ) -> Result, 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 { 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 { let data = self.data.lock().await; let mut keys: Vec = 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, }) } } /// 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, 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, AuthzError> { Ok(None) } } fn cx_with(app_id: AppId, principal: Option) -> 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 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)); } }