//! `GroupDocsServiceImpl` — wires `GroupDocsRepo` + the group-collection //! registry underneath the `picloud_shared::GroupDocsService` trait that scripts //! reach via the `docs::shared_collection("name")` Rhai handle (§11.6). //! //! Combines the group-KV service pattern (owner resolution from `cx.app_id`, //! reads-open / writes-authed authz) with the docs surface (filter parsing, //! JSON-object validation, value-size cap). No event emission in the MVP (the //! "group trigger has no app to watch" deferral). use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, NoopEventEmitter, SdkCallCx, ServiceEvent, ServiceEventEmitter, }; use crate::authz::{self, AuthzRepo, Capability}; use crate::docs_filter::{parse_filter, FilterParseError}; use crate::docs_service::docs_max_value_bytes_from_env; use crate::group_collection_repo::GroupCollectionResolver; use crate::group_docs_repo::{GroupDocsRepo, GroupDocsRepoError}; /// The registry `kind` this service resolves. const KIND_DOCS: &str = "docs"; pub struct GroupDocsServiceImpl { repo: Arc, resolver: Arc, authz: Arc, max_value_bytes: usize, /// §11.6: fires `shared = true` docs triggers on a shared-collection write. events: Arc, } impl GroupDocsServiceImpl { #[must_use] pub fn new( repo: Arc, resolver: Arc, authz: Arc, ) -> Self { Self::with_max_value_bytes(repo, resolver, authz, docs_max_value_bytes_from_env()) } /// Wire the event emitter (§11.6 shared-collection triggers). #[must_use] pub fn with_events(mut self, events: Arc) -> Self { self.events = events; self } #[must_use] pub fn with_max_value_bytes( repo: Arc, resolver: Arc, authz: Arc, max_value_bytes: usize, ) -> Self { Self { events: Arc::new(NoopEventEmitter), repo, resolver, authz, max_value_bytes, } } /// §11.6: fire `shared = true` docs triggers on the owning group. /// Best-effort; an emit failure is logged, never surfaced. #[allow(clippy::too_many_arguments)] // op/collection/id + new + old payloads async fn emit_shared( &self, cx: &SdkCallCx, group_id: GroupId, op: &'static str, collection: &str, id: &str, payload: Option, old_payload: Option, ) { if let Err(e) = self .events .emit_shared( cx, group_id, ServiceEvent { source: "docs", op, collection: Some(collection.to_string()), key: Some(id.to_string()), payload, old_payload, }, ) .await { tracing::error!(error = %e, source = "docs", op, event_emit_failure = true, "shared event emit failed"); } } /// The structural boundary: resolve the collection to its owning group /// (kind=`docs`) on the calling app's chain. `CollectionNotShared` when no /// ancestor group declares it. async fn owning_group( &self, cx: &SdkCallCx, collection: &str, ) -> Result { if collection.is_empty() { return Err(GroupDocsError::InvalidCollection); } self.resolver .resolve_owning_group(cx.app_id, collection, KIND_DOCS) .await .map_err(|e| GroupDocsError::Backend(e.to_string()))? .ok_or_else(|| GroupDocsError::CollectionNotShared(collection.to_string())) } fn check_data_size(&self, data: &serde_json::Value) -> Result<(), GroupDocsError> { let encoded_len = serde_json::to_vec(data) .map(|v| v.len()) .map_err(|e| GroupDocsError::Backend(format!("encode doc data: {e}")))?; if encoded_len > self.max_value_bytes { return Err(GroupDocsError::ValueTooLarge { limit: self.max_value_bytes, actual: encoded_len, }); } Ok(()) } async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> { authz::script_gate( &*self.authz, cx, Capability::GroupDocsRead(group_id), || GroupDocsError::Forbidden, GroupDocsError::Backend, ) .await } async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> { // 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::GroupDocsWrite(group_id), || GroupDocsError::Forbidden, GroupDocsError::Backend, ) .await } } fn validate_data(data: &serde_json::Value) -> Result<(), GroupDocsError> { if !data.is_object() { return Err(GroupDocsError::InvalidData); } Ok(()) } impl From for GroupDocsError { fn from(e: GroupDocsRepoError) -> Self { Self::Backend(e.to_string()) } } impl From for GroupDocsError { fn from(e: FilterParseError) -> Self { match e { FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s), FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s), } } } #[async_trait] impl GroupDocsService for GroupDocsServiceImpl { async fn create( &self, cx: &SdkCallCx, collection: &str, data: serde_json::Value, ) -> Result { let group_id = self.owning_group(cx, collection).await?; validate_data(&data)?; self.check_data_size(&data)?; self.check_write(cx, group_id).await?; let created = self.repo.create(group_id, collection, data.clone()).await?; self.emit_shared( cx, group_id, "create", collection, &created.id.to_string(), Some(data), None, ) .await; Ok(created.id) } async fn get( &self, cx: &SdkCallCx, collection: &str, id: DocId, ) -> Result, GroupDocsError> { let group_id = self.owning_group(cx, collection).await?; self.check_read(cx, group_id).await?; Ok(self.repo.get(group_id, collection, id).await?) } async fn find( &self, cx: &SdkCallCx, collection: &str, filter: serde_json::Value, ) -> Result, GroupDocsError> { let group_id = self.owning_group(cx, collection).await?; self.check_read(cx, group_id).await?; let parsed = parse_filter(&filter)?; Ok(self.repo.find(group_id, collection, &parsed).await?) } async fn find_one( &self, cx: &SdkCallCx, collection: &str, filter: serde_json::Value, ) -> Result, GroupDocsError> { let group_id = self.owning_group(cx, collection).await?; self.check_read(cx, group_id).await?; let mut parsed = parse_filter(&filter)?; if parsed.limit.is_none() { parsed.limit = Some(1); } let rows = self.repo.find(group_id, collection, &parsed).await?; Ok(rows.into_iter().next()) } async fn update( &self, cx: &SdkCallCx, collection: &str, id: DocId, data: serde_json::Value, ) -> Result<(), GroupDocsError> { let group_id = self.owning_group(cx, collection).await?; validate_data(&data)?; self.check_data_size(&data)?; self.check_write(cx, group_id).await?; match self .repo .update(group_id, collection, id, data.clone()) .await? { Some(prev) => { self.emit_shared( cx, group_id, "update", collection, &id.to_string(), Some(data), Some(prev), ) .await; Ok(()) } None => Err(GroupDocsError::NotFound), } } async fn delete( &self, cx: &SdkCallCx, collection: &str, id: DocId, ) -> Result { let group_id = self.owning_group(cx, collection).await?; self.check_write(cx, group_id).await?; match self.repo.delete(group_id, collection, id).await? { Some(prev) => { self.emit_shared( cx, group_id, "delete", collection, &id.to_string(), None, Some(prev), ) .await; Ok(true) } None => Ok(false), } } 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 crate::docs_filter::DocsFilter; use chrono::Utc; use picloud_shared::{ AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, UserId, }; use std::collections::HashMap; use tokio::sync::Mutex; use uuid::Uuid; #[derive(Default)] struct InMemoryGroupDocsRepo { // (group, collection) -> id -> data data: Mutex>>, } fn row(id: Uuid, data: serde_json::Value) -> DocRow { DocRow { id, data, created_at: Utc::now(), updated_at: Utc::now(), } } #[async_trait] impl GroupDocsRepo for InMemoryGroupDocsRepo { async fn create( &self, group_id: GroupId, collection: &str, data: serde_json::Value, ) -> Result { let id = Uuid::new_v4(); self.data .lock() .await .entry((group_id, collection.to_string())) .or_default() .insert(id, data.clone()); Ok(row(id, data)) } async fn get( &self, group_id: GroupId, collection: &str, id: DocId, ) -> Result, GroupDocsRepoError> { Ok(self .data .lock() .await .get(&(group_id, collection.to_string())) .and_then(|m| m.get(&id).cloned()) .map(|d| row(id, d))) } // The fake ignores the filter (filter SQL is covered by docs_repo tests // + the journey); returns all docs in the collection. async fn find( &self, group_id: GroupId, collection: &str, _filter: &DocsFilter, ) -> Result, GroupDocsRepoError> { Ok(self .data .lock() .await .get(&(group_id, collection.to_string())) .map(|m| m.iter().map(|(id, d)| row(*id, d.clone())).collect()) .unwrap_or_default()) } async fn update( &self, group_id: GroupId, collection: &str, id: DocId, data: serde_json::Value, ) -> Result, GroupDocsRepoError> { let mut guard = self.data.lock().await; let coll = guard.entry((group_id, collection.to_string())).or_default(); Ok(coll.insert(id, data)) } async fn delete( &self, group_id: GroupId, collection: &str, id: DocId, ) -> Result, GroupDocsRepoError> { Ok(self .data .lock() .await .get_mut(&(group_id, collection.to_string())) .and_then(|m| m.remove(&id))) } async fn list( &self, _group_id: GroupId, _collection: &str, _cursor: Option<&str>, _limit: u32, ) -> Result { Ok(DocsListPage { docs: vec![], next_cursor: None, }) } } #[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 svc(resolver: FakeResolver) -> GroupDocsServiceImpl { GroupDocsServiceImpl::new( Arc::new(InMemoryGroupDocsRepo::default()), Arc::new(resolver), Arc::new(DenyingAuthzRepo), ) } #[tokio::test] async fn unrelated_app_gets_collection_not_shared() { let app_a = AppId::new(); let app_b = AppId::new(); let group = GroupId::new(); let mut resolver = FakeResolver::default(); resolver.map.insert((app_a, "articles".into()), group); let docs = svc(resolver); let cx_a = cx_with(app_a, Some(owner())); let id = docs .create(&cx_a, "articles", serde_json::json!({"t": "hi"})) .await .unwrap(); assert!(docs.get(&cx_a, "articles", id).await.unwrap().is_some()); let cx_b = cx_with(app_b, Some(owner())); let err = docs .find(&cx_b, "articles", serde_json::json!({})) .await .unwrap_err(); assert!(matches!(err, GroupDocsError::CollectionNotShared(c) if c == "articles")); } #[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, "articles".into()), group); let docs = svc(resolver); let owner_cx = cx_with(app, Some(owner())); docs.create(&owner_cx, "articles", serde_json::json!({"t": "hi"})) .await .unwrap(); // Anonymous READ (find) is allowed. let anon = cx_with(app, None); let hits = docs .find(&anon, "articles", serde_json::json!({})) .await .unwrap(); assert_eq!(hits.len(), 1); // Anonymous WRITE (create) fails closed. let err = docs .create(&anon, "articles", serde_json::json!({"t": "x"})) .await .unwrap_err(); assert!(matches!(err, GroupDocsError::Forbidden)); } #[tokio::test] async fn non_object_data_rejected() { let app = AppId::new(); let group = GroupId::new(); let mut resolver = FakeResolver::default(); resolver.map.insert((app, "articles".into()), group); let docs = svc(resolver); let cx = cx_with(app, Some(owner())); let err = docs .create(&cx, "articles", serde_json::json!("not-an-object")) .await .unwrap_err(); assert!(matches!(err, GroupDocsError::InvalidData)); } }