ServiceEventEmitter gains emit_shared(cx, owning_group, event) (default no-op); OutboxEventEmitter implements it — matches shared triggers on the owning group and writes outbox rows stamped with the WRITER app_id (dispatch runs under the writer). GroupKv/Docs/FilesServiceImpl gain an events field (Noop default + with_events builder) and emit on set/create/update/delete; the host wires the outbox emitter via with_events. Closes the "group trigger has no app to watch" gap. Authoring + validation land in M2.4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
570 lines
17 KiB
Rust
570 lines
17 KiB
Rust
//! `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<dyn GroupDocsRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_value_bytes: usize,
|
|
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
|
|
events: Arc<dyn ServiceEventEmitter>,
|
|
}
|
|
|
|
impl GroupDocsServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
repo: Arc<dyn GroupDocsRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
) -> 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<dyn ServiceEventEmitter>) -> Self {
|
|
self.events = events;
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_max_value_bytes(
|
|
repo: Arc<dyn GroupDocsRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
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<serde_json::Value>,
|
|
old_payload: Option<serde_json::Value>,
|
|
) {
|
|
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<GroupId, GroupDocsError> {
|
|
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<GroupDocsRepoError> for GroupDocsError {
|
|
fn from(e: GroupDocsRepoError) -> Self {
|
|
Self::Backend(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<FilterParseError> 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<DocId, 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?;
|
|
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<Option<DocRow>, 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<Vec<DocRow>, 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<Option<DocRow>, 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<bool, GroupDocsError> {
|
|
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<DocsListPage, GroupDocsError> {
|
|
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<HashMap<(GroupId, String), HashMap<Uuid, serde_json::Value>>>,
|
|
}
|
|
|
|
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<DocRow, GroupDocsRepoError> {
|
|
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<Option<DocRow>, 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<Vec<DocRow>, 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<Option<serde_json::Value>, 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<Option<serde_json::Value>, 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<DocsListPage, GroupDocsRepoError> {
|
|
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<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 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));
|
|
}
|
|
}
|