Global env-var ceilings enforced in the group write path (mirrors the per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row count, checked only on a NEW key/doc — updates exempt) and _FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres); QuotaExceeded error variants on the three group error enums; a with_max_rows test builder. Pinned by a group_kv_service unit test (3rd key rejected, update of an existing key at the cap allowed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
558 lines
18 KiB
Rust
558 lines
18 KiB
Rust
//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection
|
|
//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts
|
|
//! reach via the `files::shared_collection("name")` Rhai handle (§11.6).
|
|
//!
|
|
//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`,
|
|
//! reads-open / writes-authed authz) with the files surface (collection
|
|
//! path-validation, field + size-cap validation, content-type sanitization). 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::{
|
|
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
|
|
GroupFilesError, GroupFilesService, GroupId, NewFile, NoopEventEmitter, SdkCallCx,
|
|
ServiceEvent, ServiceEventEmitter,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use crate::authz::{self, AuthzRepo, Capability};
|
|
use crate::files_repo::FileUpdated;
|
|
use crate::group_collection_repo::GroupCollectionResolver;
|
|
use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError};
|
|
|
|
/// The registry `kind` this service resolves.
|
|
const KIND_FILES: &str = "files";
|
|
|
|
pub struct GroupFilesServiceImpl {
|
|
repo: Arc<dyn GroupFilesRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_file_size_bytes: usize,
|
|
/// §11.6 per-group quota: max total stored bytes across the group's
|
|
/// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`).
|
|
max_total_bytes: u64,
|
|
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
|
|
events: Arc<dyn ServiceEventEmitter>,
|
|
}
|
|
|
|
impl GroupFilesServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
repo: Arc<dyn GroupFilesRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_file_size_bytes: usize,
|
|
) -> Self {
|
|
Self {
|
|
repo,
|
|
resolver,
|
|
authz,
|
|
max_file_size_bytes,
|
|
max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(),
|
|
events: Arc::new(NoopEventEmitter),
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// §11.6: fire `shared = true` files triggers on the owning group.
|
|
/// Best-effort; an emit failure is logged, never surfaced.
|
|
async fn emit_shared(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
group_id: GroupId,
|
|
op: &'static str,
|
|
collection: &str,
|
|
meta: &FileMeta,
|
|
old: Option<&FileMeta>,
|
|
) {
|
|
if let Err(e) = self
|
|
.events
|
|
.emit_shared(
|
|
cx,
|
|
group_id,
|
|
ServiceEvent {
|
|
source: "files",
|
|
op,
|
|
collection: Some(collection.to_string()),
|
|
key: Some(meta.id.to_string()),
|
|
payload: serde_json::to_value(meta).ok(),
|
|
old_payload: old.and_then(|m| serde_json::to_value(m).ok()),
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(error = %e, source = "files", op, event_emit_failure = true, "shared event emit failed");
|
|
}
|
|
}
|
|
|
|
/// The structural boundary: resolve the collection to its owning group
|
|
/// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no
|
|
/// ancestor group declares it.
|
|
async fn owning_group(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
) -> Result<GroupId, GroupFilesError> {
|
|
self.resolver
|
|
.resolve_owning_group(cx.app_id, collection, KIND_FILES)
|
|
.await
|
|
.map_err(|e| GroupFilesError::Backend(e.to_string()))?
|
|
.ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string()))
|
|
}
|
|
|
|
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
|
authz::script_gate(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::GroupFilesRead(group_id),
|
|
|| GroupFilesError::Forbidden,
|
|
GroupFilesError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
|
// 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::GroupFilesWrite(group_id),
|
|
|| GroupFilesError::Forbidden,
|
|
GroupFilesError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they
|
|
/// simply mean "no such file" (mirrors `files_service::parse_id`).
|
|
fn parse_id(id: &str) -> Option<Uuid> {
|
|
Uuid::parse_str(id).ok()
|
|
}
|
|
|
|
impl From<GroupFilesRepoError> for GroupFilesError {
|
|
fn from(e: GroupFilesRepoError) -> Self {
|
|
match e {
|
|
GroupFilesRepoError::Corrupted => Self::Corrupted,
|
|
GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
|
other => Self::Backend(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupFilesService for GroupFilesServiceImpl {
|
|
async fn create(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
mut new: NewFile,
|
|
) -> Result<Uuid, GroupFilesError> {
|
|
validate_files_collection(collection)?;
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
new.validate(self.max_file_size_bytes)?;
|
|
// Coerce dangerous render types to application/octet-stream after the
|
|
// shape checks pass (same as app files, audit 2026-06-11 C-2).
|
|
new.content_type = sanitize_stored_content_type(&new.content_type);
|
|
self.check_write(cx, group_id).await?;
|
|
// §11.6 quota: the new file's bytes must fit under the group's total.
|
|
let used = self.repo.total_bytes(group_id).await?;
|
|
let incoming = new.data.len() as u64;
|
|
if used.saturating_add(incoming) > self.max_total_bytes {
|
|
return Err(GroupFilesError::QuotaExceeded {
|
|
limit: self.max_total_bytes,
|
|
actual: used.saturating_add(incoming),
|
|
});
|
|
}
|
|
let meta = self.repo.create(group_id, collection, new).await?;
|
|
self.emit_shared(cx, group_id, "create", collection, &meta, None)
|
|
.await;
|
|
Ok(meta.id)
|
|
}
|
|
|
|
async fn head(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: &str,
|
|
) -> Result<Option<FileMeta>, GroupFilesError> {
|
|
validate_files_collection(collection)?;
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
let Some(uuid) = parse_id(id) else {
|
|
return Ok(None);
|
|
};
|
|
Ok(self.repo.head(group_id, collection, uuid).await?)
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: &str,
|
|
) -> Result<Option<Vec<u8>>, GroupFilesError> {
|
|
validate_files_collection(collection)?;
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
let Some(uuid) = parse_id(id) else {
|
|
return Ok(None);
|
|
};
|
|
Ok(self.repo.get(group_id, collection, uuid).await?)
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: &str,
|
|
mut upd: FileUpdate,
|
|
) -> Result<(), GroupFilesError> {
|
|
validate_files_collection(collection)?;
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
upd.validate(self.max_file_size_bytes)?;
|
|
if let Some(ct) = upd.content_type.as_deref() {
|
|
upd.content_type = Some(sanitize_stored_content_type(ct));
|
|
}
|
|
self.check_write(cx, group_id).await?;
|
|
let Some(uuid) = parse_id(id) else {
|
|
return Err(GroupFilesError::NotFound);
|
|
};
|
|
match self.repo.update(group_id, collection, uuid, upd).await? {
|
|
Some(FileUpdated { new, prev }) => {
|
|
self.emit_shared(cx, group_id, "update", collection, &new, Some(&prev))
|
|
.await;
|
|
Ok(())
|
|
}
|
|
None => Err(GroupFilesError::NotFound),
|
|
}
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: &str,
|
|
) -> Result<bool, GroupFilesError> {
|
|
validate_files_collection(collection)?;
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_write(cx, group_id).await?;
|
|
let Some(uuid) = parse_id(id) else {
|
|
return Ok(false);
|
|
};
|
|
match self.repo.delete(group_id, collection, uuid).await? {
|
|
Some(meta) => {
|
|
self.emit_shared(cx, group_id, "delete", collection, &meta, Some(&meta))
|
|
.await;
|
|
Ok(true)
|
|
}
|
|
None => Ok(false),
|
|
}
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<FilesListPage, GroupFilesError> {
|
|
validate_files_collection(collection)?;
|
|
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 or
|
|
// a filesystem. The on-disk atomic-write/checksum mechanics are covered by the
|
|
// tempdir tests in `files_repo.rs`.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::authz::{AuthzError, AuthzRepo};
|
|
use crate::group_files_repo::GroupFilesRepoError;
|
|
use chrono::Utc;
|
|
use picloud_shared::{
|
|
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
|
UserId,
|
|
};
|
|
use std::collections::HashMap;
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryGroupFilesRepo {
|
|
#[allow(clippy::type_complexity)]
|
|
data: Mutex<HashMap<(GroupId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
|
}
|
|
|
|
fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta {
|
|
FileMeta {
|
|
id,
|
|
collection: collection.to_string(),
|
|
name: new.name.clone(),
|
|
content_type: new.content_type.clone(),
|
|
size: new.data.len() as u64,
|
|
checksum: String::new(),
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupFilesRepo for InMemoryGroupFilesRepo {
|
|
async fn create(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
new: NewFile,
|
|
) -> Result<FileMeta, GroupFilesRepoError> {
|
|
let id = Uuid::new_v4();
|
|
let m = meta(id, collection, &new);
|
|
self.data.lock().await.insert(
|
|
(group_id, collection.to_string(), id),
|
|
(m.clone(), new.data),
|
|
);
|
|
Ok(m)
|
|
}
|
|
|
|
async fn head(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
id: Uuid,
|
|
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(group_id, collection.to_string(), id))
|
|
.map(|(m, _)| m.clone()))
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
id: Uuid,
|
|
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(group_id, collection.to_string(), id))
|
|
.map(|(_, b)| b.clone()))
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
id: Uuid,
|
|
upd: FileUpdate,
|
|
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
|
let mut data = self.data.lock().await;
|
|
let key = (group_id, collection.to_string(), id);
|
|
let Some((prev, _)) = data.get(&key).cloned() else {
|
|
return Ok(None);
|
|
};
|
|
let mut m = prev.clone();
|
|
m.size = upd.data.len() as u64;
|
|
if let Some(n) = &upd.name {
|
|
m.name = n.clone();
|
|
}
|
|
data.insert(key, (m.clone(), upd.data));
|
|
Ok(Some(FileUpdated { new: m, prev }))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
id: Uuid,
|
|
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.remove(&(group_id, collection.to_string(), id))
|
|
.map(|(m, _)| m))
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
_cursor: Option<&str>,
|
|
_limit: u32,
|
|
) -> Result<FilesListPage, GroupFilesRepoError> {
|
|
let files = self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.iter()
|
|
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
|
.map(|(_, (m, _))| m.clone())
|
|
.collect();
|
|
Ok(FilesListPage {
|
|
files,
|
|
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) -> GroupFilesServiceImpl {
|
|
GroupFilesServiceImpl::new(
|
|
Arc::new(InMemoryGroupFilesRepo::default()),
|
|
Arc::new(resolver),
|
|
Arc::new(DenyingAuthzRepo),
|
|
10 * 1024 * 1024,
|
|
)
|
|
}
|
|
|
|
fn new_file(name: &str, data: &[u8]) -> NewFile {
|
|
NewFile {
|
|
name: name.to_string(),
|
|
content_type: "application/octet-stream".to_string(),
|
|
data: data.to_vec(),
|
|
}
|
|
}
|
|
|
|
#[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, "assets".into()), group);
|
|
let files = svc(resolver);
|
|
|
|
let cx_a = cx_with(app_a, Some(owner()));
|
|
let id = files
|
|
.create(&cx_a, "assets", new_file("a.txt", b"hi"))
|
|
.await
|
|
.unwrap();
|
|
assert!(files
|
|
.get(&cx_a, "assets", &id.to_string())
|
|
.await
|
|
.unwrap()
|
|
.is_some());
|
|
|
|
let cx_b = cx_with(app_b, Some(owner()));
|
|
let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err();
|
|
assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets"));
|
|
}
|
|
|
|
#[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, "assets".into()), group);
|
|
let files = svc(resolver);
|
|
|
|
let owner_cx = cx_with(app, Some(owner()));
|
|
let id = files
|
|
.create(&owner_cx, "assets", new_file("a.txt", b"hi"))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Anonymous READ (get) is allowed.
|
|
let anon = cx_with(app, None);
|
|
let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap();
|
|
assert_eq!(bytes, Some(b"hi".to_vec()));
|
|
|
|
// Anonymous WRITE (create) fails closed.
|
|
let err = files
|
|
.create(&anon, "assets", new_file("x.txt", b"x"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, GroupFilesError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oversize_blob_rejected() {
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app, "assets".into()), group);
|
|
let files = GroupFilesServiceImpl::new(
|
|
Arc::new(InMemoryGroupFilesRepo::default()),
|
|
Arc::new(resolver),
|
|
Arc::new(DenyingAuthzRepo),
|
|
8, // tiny cap
|
|
);
|
|
let cx = cx_with(app, Some(owner()));
|
|
let err = files
|
|
.create(&cx, "assets", new_file("big", b"123456789"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. }));
|
|
}
|
|
}
|